diff --git a/Assets/AssetCaches.asset b/Assets/AssetCaches.asset index 84b305f98..0f57721b3 100644 --- a/Assets/AssetCaches.asset +++ b/Assets/AssetCaches.asset @@ -18583,6 +18583,21 @@ MonoBehaviour: - {fileID: 102900000, guid: aa3f5467c0c153642ac320466aee0ec1, type: 3} FilterEnum: 0 Filter: '*' + - Path: Assets/ResRaw/Prefabs/Line/FishingLine1.prefab + Address: Plyaer/FishingLine1 + Type: GameObject + Bundle: main/plyaer.bundle + Tags: + Group: + Name: Plyaer + Enable: 1 + BundleMode: 0 + AddressMode: 2 + Tags: + Collectors: + - {fileID: 102900000, guid: aa3f5467c0c153642ac320466aee0ec1, type: 3} + FilterEnum: 0 + Filter: '*' - Path: Assets/ResRaw/Prefabs/Line/FishingRopeLong.asset Address: Plyaer/FishingRopeLong Type: Missing diff --git a/Assets/ResRaw/Prefabs/Line/FishingLine1.prefab b/Assets/ResRaw/Prefabs/Line/FishingLine1.prefab new file mode 100644 index 000000000..626a6ca3f --- /dev/null +++ b/Assets/ResRaw/Prefabs/Line/FishingLine1.prefab @@ -0,0 +1,575 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &2696931885206049402 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 6213026895670800501} + - component: {fileID: 7362979975150531515} + - component: {fileID: 5572865435543895569} + m_Layer: 0 + m_Name: Start + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &6213026895670800501 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2696931885206049402} + 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} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 98221710317492190} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!54 &7362979975150531515 +Rigidbody: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2696931885206049402} + serializedVersion: 5 + m_Mass: 1 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + 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: 0 + m_Interpolate: 0 + m_Constraints: 0 + m_CollisionDetection: 0 +--- !u!114 &5572865435543895569 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 2696931885206049402} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 610df5569209e4b4997cb2dbf3b94cdc, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::NBF.FishingLineNode + nodeType: 0 + body: {fileID: 7362979975150531515} + interaction: {fileID: 0} + segmentLengthToNext: 0.5 + fixedVirtualNodesToNext: 2 + runtimeVirtualNode: 0 + runtimeChainIndex: -1 + drawDebugGizmo: 1 + debugGizmoRadius: 0.03 + logicalNodeColor: {r: 0.2, g: 0.9, b: 0.2, a: 1} + virtualNodeColor: {r: 1, g: 0.6, b: 0.1, a: 1} + features: [] + motionFeatures: [] + activeMotionFeature: {fileID: 0} +--- !u!1 &5077741257619886775 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 7385546574667729905} + - component: {fileID: 5707703654405666688} + - component: {fileID: 3979683508768218053} + - component: {fileID: 250386986656750139} + m_Layer: 0 + m_Name: End + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &7385546574667729905 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5077741257619886775} + 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} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 98221710317492190} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!54 &5707703654405666688 +Rigidbody: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5077741257619886775} + serializedVersion: 5 + m_Mass: 0.01 + m_LinearDamping: 1 + m_AngularDamping: 0.2 + 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: 0 + m_Interpolate: 0 + m_Constraints: 0 + m_CollisionDetection: 0 +--- !u!114 &3979683508768218053 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5077741257619886775} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 610df5569209e4b4997cb2dbf3b94cdc, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::NBF.FishingLineNode + nodeType: 3 + body: {fileID: 5707703654405666688} + interaction: {fileID: 0} + segmentLengthToNext: 0.5 + fixedVirtualNodesToNext: 2 + runtimeVirtualNode: 0 + runtimeChainIndex: -1 + drawDebugGizmo: 1 + debugGizmoRadius: 0.03 + logicalNodeColor: {r: 0.2, g: 0.9, b: 0.2, a: 1} + virtualNodeColor: {r: 1, g: 0.6, b: 0.1, a: 1} + features: [] + motionFeatures: [] + activeMotionFeature: {fileID: 0} +--- !u!65 &250386986656750139 +BoxCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5077741257619886775} + 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: 3 + m_Size: {x: 0.02, y: 0.02, z: 0.02} + m_Center: {x: 0, y: 0, z: 0} +--- !u!1 &5252216124238432432 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 3116177874895914436} + - component: {fileID: 1033618195002336566} + - component: {fileID: 9117070148710828175} + - component: {fileID: 4152162740525283091} + - component: {fileID: 2513762410452133691} + m_Layer: 0 + m_Name: Bobber + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &3116177874895914436 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5252216124238432432} + 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} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 98221710317492190} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!54 &1033618195002336566 +Rigidbody: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5252216124238432432} + serializedVersion: 5 + m_Mass: 1 + m_LinearDamping: 0 + m_AngularDamping: 0.05 + 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: 0 + m_ImplicitTensor: 0 + m_UseGravity: 1 + m_IsKinematic: 0 + m_Interpolate: 0 + m_Constraints: 0 + m_CollisionDetection: 0 +--- !u!136 &9117070148710828175 +CapsuleCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5252216124238432432} + 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 + m_Radius: 0.003 + m_Height: 0.02 + m_Direction: 1 + m_Center: {x: 0, y: 0.03, z: 0} +--- !u!114 &4152162740525283091 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5252216124238432432} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 610df5569209e4b4997cb2dbf3b94cdc, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::NBF.FishingLineNode + nodeType: 1 + body: {fileID: 1033618195002336566} + interaction: {fileID: 0} + segmentLengthToNext: 0.5 + fixedVirtualNodesToNext: 2 + runtimeVirtualNode: 0 + runtimeChainIndex: -1 + drawDebugGizmo: 1 + debugGizmoRadius: 0.03 + logicalNodeColor: {r: 0.2, g: 0.9, b: 0.2, a: 1} + virtualNodeColor: {r: 1, g: 0.6, b: 0.1, a: 1} + features: [] + motionFeatures: [] + activeMotionFeature: {fileID: 0} +--- !u!114 &2513762410452133691 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5252216124238432432} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: ca4d5d54d89446b0a10b7ce521fd7d9e, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::NBF.FishingBobberFeature + priorityOffset: 0 + waterLevel: 0 + enterWaterDepth: 0.002 + exitWaterDepth: -0.01 + floatHeight: 0.08 + bottomOffsetLocalY: 0 + ySmoothTime: 0.08 + maxYSpeed: 2 + yDeadZone: 0.0005 + neutralBottomWeight: 1 + currentBottomWeight: 1 + baseDraftDepth: 0.02 + draftDepthPerWeight: 0.01 + minDraftDepth: 0.005 + maxDraftDepth: 0.08 + draftSmoothTime: 0.18 + biteSmoothTime: 0.03 + tapAmplitude: 0.008 + tapDuration: 0.18 + slowSinkAmplitude: 0.025 + slowSinkDuration: 1.2 + liftAmplitude: 0.015 + liftDuration: 1.2 + blackDriftAmplitude: 0.06 + blackDriftDuration: 0.8 + enableDebugInput: 1 + stopBiteKey: 114 + tapKey: 116 + slowSinkKey: 103 + liftKey: 104 + blackDriftKey: 98 + lyingWeightThreshold: 0.4 + tiltedWeightThreshold: 0.8 + uprightWeightThreshold: 1.2 + lyingAngle: 88 + tiltedAngle: 42 + uprightAngle: 0 + tiltAxis: 0 + invertTiltDirection: 0 + rotationLerpSpeed: 8 + waterAngularDamping: 999 +--- !u!1 &5438655829551842420 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 98221710317492190} + - component: {fileID: 913314649585263376} + - component: {fileID: 6821754774284524478} + - component: {fileID: 1999186176030474616} + m_Layer: 0 + m_Name: FishingLine1 + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &98221710317492190 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5438655829551842420} + 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} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 6213026895670800501} + - {fileID: 3116177874895914436} + - {fileID: 7385546574667729905} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!114 &913314649585263376 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5438655829551842420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dcd0fd8d96f994444b2d8663af6b915d, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::NBF.FishingLineSolver + anchorTransform: {fileID: 0} + logicalNodes: + - {fileID: 5572865435543895569} + - {fileID: 4152162740525283091} + - {fileID: 3979683508768218053} + lineRenderer: {fileID: 1999186176030474616} + firstSegmentLength: 1.2 + firstSegmentStep: 0.1 + jointSolverIterations: 12 + jointProjectionDistance: 0.02 + jointProjectionAngle: 1 + lengthLimitTolerance: 0.01 + breakStretchThreshold: 0.05 + breakLimitDuration: 0.15 + autoBuildOnStart: 1 +--- !u!120 &6821754774284524478 +LineRenderer: + serializedVersion: 3 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5438655829551842420} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 0 + m_LightProbeUsage: 0 + m_ReflectionProbeUsage: 0 + m_RayTracingMode: 0 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 0} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 0 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_Positions: + - {x: 0, y: 0, z: 0} + - {x: 0, y: 1, z: 0} + m_Parameters: + serializedVersion: 3 + widthMultiplier: 1 + widthCurve: + serializedVersion: 2 + m_Curve: + - serializedVersion: 3 + time: 0 + value: 0.01 + inSlope: 0 + outSlope: 0 + tangentMode: 0 + weightedMode: 0 + inWeight: 0.33333334 + outWeight: 0.33333334 + m_PreInfinity: 2 + m_PostInfinity: 2 + m_RotationOrder: 4 + colorGradient: + serializedVersion: 2 + key0: {r: 1, g: 1, b: 1, a: 1} + key1: {r: 1, g: 1, b: 1, a: 1} + key2: {r: 0, g: 0, b: 0, a: 0} + key3: {r: 0, g: 0, b: 0, a: 0} + key4: {r: 0, g: 0, b: 0, a: 0} + key5: {r: 0, g: 0, b: 0, a: 0} + key6: {r: 0, g: 0, b: 0, a: 0} + key7: {r: 0, g: 0, b: 0, a: 0} + ctime0: 0 + ctime1: 65535 + ctime2: 0 + ctime3: 0 + ctime4: 0 + ctime5: 0 + ctime6: 0 + ctime7: 0 + atime0: 0 + atime1: 65535 + atime2: 0 + atime3: 0 + atime4: 0 + atime5: 0 + atime6: 0 + atime7: 0 + m_Mode: 0 + m_ColorSpace: -1 + m_NumColorKeys: 2 + m_NumAlphaKeys: 2 + numCornerVertices: 0 + numCapVertices: 0 + alignment: 0 + textureMode: 0 + textureScale: {x: 1, y: 1} + shadowBias: 0.5 + generateLightingData: 0 + m_UseWorldSpace: 1 + m_Loop: 0 + m_ApplyActiveColorSpace: 1 +--- !u!114 &1999186176030474616 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 5438655829551842420} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 827786ffede4e7b4781c522e8a4ba9d0, type: 3} + m_Name: + m_EditorClassIdentifier: Assembly-CSharp::NBF.FishingLineRenderer + solver: {fileID: 913314649585263376} + lineRenderer: {fileID: 6821754774284524478} + solverIterations: 8 + damping: 0.98 + gravityScale: 1 + simulationStep: 0.0166667 + maxDeltaTime: 0.0333333 + constrainToWaterSurface: 1 + waterSurfaceTransform: {fileID: 0} + waterSurfaceHeight: 0 + ignoreHeadNodeCount: 1 + ignoreTailNodeCount: 1 + waterSurfaceFollowSpeed: 12 + maxSubStepsPerFrame: 2 + sleepVelocityThreshold: 0.001 + sleepDistanceThreshold: 0.002 + stableFramesBeforeSleep: 4 + wakeDistanceThreshold: 0.001 + tautSegmentThreshold: 0.002 + tautTransitionRange: 0.03 + smoothCorners: 1 + minCornerAngle: 12 + maxCornerSmoothDistance: 0.03 + cornerSmoothSubdivisions: 3 + drawDebugSamples: 0 + debugLogicalSampleColor: {r: 0, g: 1, b: 1, a: 1} + debugVirtualSampleColor: {r: 1, g: 0.55, b: 0.15, a: 1} + debugLogicalSampleRadius: 0.018 + debugVirtualSampleRadius: 0.012 diff --git a/Assets/ResRaw/Prefabs/Line/FishingLine1.prefab.meta b/Assets/ResRaw/Prefabs/Line/FishingLine1.prefab.meta new file mode 100644 index 000000000..86445cc09 --- /dev/null +++ b/Assets/ResRaw/Prefabs/Line/FishingLine1.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 488209094f0c45a41aa6801dd86e6768 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/ResRaw/Prefabs/Line/fishing line float set.prefab b/Assets/ResRaw/Prefabs/Line/fishing line float set.prefab index 6c61496bc..96bcebe54 100644 --- a/Assets/ResRaw/Prefabs/Line/fishing line float set.prefab +++ b/Assets/ResRaw/Prefabs/Line/fishing line float set.prefab @@ -675,7 +675,7 @@ MonoBehaviour: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 1858052053854210} - m_Enabled: 0 + m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 2dedfafdc2d747d98c682cde3e28e513, type: 3} m_Name: diff --git a/Assets/Scripts/Fishing/New/View/FishingLine.meta b/Assets/Scripts/Fishing/New/View/FishingLine.meta new file mode 100644 index 000000000..a48188375 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6901448ac9466974791a863c357f6579 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature.meta b/Assets/Scripts/Fishing/New/View/FishingLine/Feature.meta new file mode 100644 index 000000000..0ab8f5267 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2ff164cb3132445289d22c7b3a0f4fab +timeCreated: 1775957622 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingBobberFeature.cs b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingBobberFeature.cs new file mode 100644 index 000000000..36cd61a27 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingBobberFeature.cs @@ -0,0 +1,664 @@ +using UnityEngine; + +namespace NBF +{ + public enum FishingBobberControlMode + { + AirPhysics, + WaterPresentation, + } + + public enum FishingBobberBiteType + { + None, + Tap, + SlowSink, + Lift, + BlackDrift, + } + + public enum BobberTiltAxis + { + LocalX, + LocalZ, + } + + [DisallowMultipleComponent] + [RequireComponent(typeof(Rigidbody))] + public class FishingBobberFeature : FishingLineNodeMotionFeature + { + protected override int DefaultPriority => 100; + + #region 检测入水 + + [Header("入水检测")] + [Tooltip("当前测试阶段固定水面高度。")] + public float waterLevel = 0f; + + [Tooltip("浮漂底部进入水面达到该深度后,切换为水面表现控制。")] + public float enterWaterDepth = 0.002f; + + [Tooltip("浮漂底部高于该深度时退出水面表现控制。通常设为负值用于滞回。")] + public float exitWaterDepth = -0.01f; + + [Tooltip("浮漂总高度,单位米。")] + public float floatHeight = 0.08f; + + [Tooltip("如果 Pivot 在浮漂底部填 0;如果 Pivot 在模型中部,填底部相对 Pivot 的本地 Y 偏移。")] + public float bottomOffsetLocalY; + + [Tooltip("Y 轴控制的平滑时间。")] + public float ySmoothTime = 0.08f; + + [Tooltip("Y 轴平滑时允许的最大竖直速度。")] + public float maxYSpeed = 2f; + + [Tooltip("Y 轴死区,小范围内直接贴目标值以减少微抖。")] + public float yDeadZone = 0.0005f; + + #endregion + + #region 吃水控制 + + [Header("吃水控制")] + [Tooltip("基准底部重量。当前重量等于该值时,使用基础吃水深度。")] + public float neutralBottomWeight = 1f; + + [Tooltip("当前底部总重量。运行时可由其他脚本更新。")] + public float currentBottomWeight = 1f; + + [Tooltip("基准重量下的基础吃水深度。")] + public float baseDraftDepth = 0.02f; + + [Tooltip("每单位重量变化对应增加或减少的吃水深度。")] + public float draftDepthPerWeight = 0.01f; + + [Tooltip("吃水深度下限。")] + public float minDraftDepth = 0.005f; + + [Tooltip("吃水深度上限。")] + public float maxDraftDepth = 0.08f; + + [Tooltip("吃水深度变化的平滑时间。")] + public float draftSmoothTime = 0.18f; + + #endregion + + #region 漂相动画 + + [Header("漂相动画")] + [Tooltip("漂相位移动画的平滑时间。")] + public float biteSmoothTime = 0.03f; + + [Tooltip("点漂默认振幅。")] + public float tapAmplitude = 0.008f; + + [Tooltip("点漂默认时长。")] + public float tapDuration = 0.18f; + + [Tooltip("缓沉默认振幅。")] + public float slowSinkAmplitude = 0.025f; + + [Tooltip("缓沉默认时长。")] + public float slowSinkDuration = 1.2f; + + [Tooltip("顶漂默认振幅。")] + public float liftAmplitude = 0.015f; + + [Tooltip("顶漂默认时长。")] + public float liftDuration = 1.2f; + + [Tooltip("黑漂默认振幅。")] + public float blackDriftAmplitude = 0.06f; + + [Tooltip("黑漂默认时长。")] + public float blackDriftDuration = 0.8f; + + #endregion + + #region 输入测试 + + [Header("输入测试")] + [Tooltip("是否启用运行时按键测试漂相。")] + public bool enableDebugInput = true; + + [Tooltip("停止当前漂相的按键。")] + public KeyCode stopBiteKey = KeyCode.R; + + [Tooltip("触发点漂的按键。")] + public KeyCode tapKey = KeyCode.T; + + [Tooltip("触发缓沉的按键。")] + public KeyCode slowSinkKey = KeyCode.G; + + [Tooltip("触发顶漂的按键。")] + public KeyCode liftKey = KeyCode.H; + + [Tooltip("触发黑漂的按键。")] + public KeyCode blackDriftKey = KeyCode.B; + + #endregion + + #region 姿态控制 + + [Header("姿态控制")] + [Tooltip("重量低于该值时,姿态趋向躺漂。")] + public float lyingWeightThreshold = 0.4f; + + [Tooltip("重量达到该值附近时,姿态趋向半躺。")] + public float tiltedWeightThreshold = 0.8f; + + [Tooltip("重量达到该值及以上时,姿态趋向立漂。")] + public float uprightWeightThreshold = 1.2f; + + [Tooltip("躺漂对应的倾角。")] + public float lyingAngle = 88f; + + [Tooltip("半躺对应的倾角。")] + public float tiltedAngle = 42f; + + [Tooltip("立漂对应的倾角,通常为 0。")] + public float uprightAngle = 0f; + + [Tooltip("绕哪个本地轴做倾倒。")] + public BobberTiltAxis tiltAxis = BobberTiltAxis.LocalX; + + [Tooltip("是否反转倾倒方向。")] + public bool invertTiltDirection; + + [Tooltip("姿态旋转的平滑速度。")] + public float rotationLerpSpeed = 8f; + + [Tooltip("入水后用于压制旋转抖动的角阻尼。")] + public float waterAngularDamping = 999f; + + #endregion + + #region 运行时状态 + + public FishingBobberControlMode CurrentMode => _mode; + public FishingBobberBiteType CurrentBiteType => _activeBiteType; + public float CurrentDraftDepth => _currentDraftDepth; + public float CurrentBottomWeight => currentBottomWeight; + + private Rigidbody _rb; + private FishingBobberControlMode _mode = FishingBobberControlMode.AirPhysics; + + private bool _defaultsCached; + private bool _waterStateInitialized; + private float _defaultAngularDamping; + private bool _defaultUseGravity; + + private float _draftVelocity; + private float _currentDraftDepth; + private float _ySmoothVelocity; + private float _biteOffsetY; + private float _biteOffsetYVelocity; + + private Quaternion _uprightReferenceRotation; + private Quaternion _targetRotation; + + private FishingBobberBiteType _activeBiteType = FishingBobberBiteType.None; + private float _biteTimer; + private float _biteDuration; + private float _biteAmplitude; + + #endregion + + #region Unity 生命周期 + + private void Awake() + { + EnsureRuntimeReferences(); + InitializeRuntimeState(); + } + + private void Update() + { + HandleDebugInput(); + } + + #endregion + + #region 运动控制接入 + + public override bool IsSupportedNode(FishingLineNode node) + { + return node != null && node.Type == FishingLineNode.NodeType.Float; + } + + protected override void OnBind() + { + EnsureRuntimeReferences(); + InitializeRuntimeState(); + } + + public override bool CanControl() + { + EnsureRuntimeReferences(); + if (_rb == null || !IsSupportedNode(Node)) + { + return false; + } + + var submergeDepth = GetSubmergeDepth(); + if (_mode == FishingBobberControlMode.WaterPresentation) + { + return submergeDepth >= exitWaterDepth; + } + + return submergeDepth > enterWaterDepth; + } + + public override void OnMotionActivated() + { + EnsureRuntimeReferences(); + EnterWaterPresentationMode(); + } + + public override void OnMotionDeactivated() + { + EnsureRuntimeReferences(); + ExitWaterPresentationMode(); + } + + public override void TickMotion(float deltaTime) + { + EnsureRuntimeReferences(); + if (_rb == null) + { + return; + } + + var submergeDepth = GetSubmergeDepth(); + if (submergeDepth < exitWaterDepth) + { + ExitWaterPresentationMode(); + return; + } + + if (_mode != FishingBobberControlMode.WaterPresentation) + { + EnterWaterPresentationMode(); + } + + UpdateBiteAnimation(deltaTime); + UpdateDraft(deltaTime); + UpdateVerticalPosition(deltaTime); + UpdateRotation(deltaTime); + } + + #endregion + + #region 漂相接口 + + public void SetBottomWeight(float weight) + { + currentBottomWeight = weight; + } + + public void PlayTap(float amplitude = -1f, float duration = -1f) + { + StartBite( + FishingBobberBiteType.Tap, + amplitude > 0f ? amplitude : tapAmplitude, + duration > 0f ? duration : tapDuration); + } + + public void PlaySlowSink(float amplitude = -1f, float duration = -1f) + { + StartBite( + FishingBobberBiteType.SlowSink, + amplitude > 0f ? amplitude : slowSinkAmplitude, + duration > 0f ? duration : slowSinkDuration); + } + + public void PlayLift(float amplitude = -1f, float duration = -1f) + { + StartBite( + FishingBobberBiteType.Lift, + amplitude > 0f ? amplitude : liftAmplitude, + duration > 0f ? duration : liftDuration); + } + + public void PlayBlackDrift(float amplitude = -1f, float duration = -1f) + { + StartBite( + FishingBobberBiteType.BlackDrift, + amplitude > 0f ? amplitude : blackDriftAmplitude, + duration > 0f ? duration : blackDriftDuration); + } + + public void StopBite() + { + _activeBiteType = FishingBobberBiteType.None; + _biteTimer = 0f; + _biteDuration = 0f; + _biteAmplitude = 0f; + } + + #endregion + + #region 内部实现 + + private void EnsureRuntimeReferences() + { + if (_rb == null) + { + _rb = Node != null && Node.Body != null ? Node.Body : GetComponent(); + } + } + + private void InitializeRuntimeState() + { + if (_rb == null) + { + return; + } + + if (!_defaultsCached) + { + _defaultAngularDamping = _rb.angularDamping; + _defaultUseGravity = _rb.useGravity; + _defaultsCached = true; + } + + _currentDraftDepth = CalculateRawDraftDepth(); + _draftVelocity = 0f; + _ySmoothVelocity = 0f; + _biteOffsetY = 0f; + _biteOffsetYVelocity = 0f; + _targetRotation = transform.rotation; + + if (!_waterStateInitialized) + { + _uprightReferenceRotation = transform.rotation; + } + } + + private void EnterWaterPresentationMode() + { + if (_rb == null) + { + return; + } + + _mode = FishingBobberControlMode.WaterPresentation; + _waterStateInitialized = true; + _uprightReferenceRotation = transform.rotation; + _targetRotation = transform.rotation; + _draftVelocity = 0f; + _ySmoothVelocity = 0f; + _biteOffsetYVelocity = 0f; + _currentDraftDepth = CalculateRawDraftDepth(); + + _rb.useGravity = false; + _rb.angularDamping = waterAngularDamping; + } + + private void ExitWaterPresentationMode() + { + _mode = FishingBobberControlMode.AirPhysics; + RestorePhysicsState(); + } + + private void RestorePhysicsState() + { + if (_rb == null || !_defaultsCached) + { + return; + } + + _rb.useGravity = _defaultUseGravity; + _rb.angularDamping = _defaultAngularDamping; + } + + private float GetSubmergeDepth() + { + return waterLevel - GetBottomWorldPosition().y; + } + + private Vector3 GetBottomWorldPosition() + { + return transform.TransformPoint(new Vector3(0f, bottomOffsetLocalY, 0f)); + } + + private float CalculateRawDraftDepth() + { + var weightDelta = currentBottomWeight - neutralBottomWeight; + var targetDraft = baseDraftDepth + weightDelta * draftDepthPerWeight; + return Mathf.Clamp(targetDraft, minDraftDepth, maxDraftDepth); + } + + private void UpdateDraft(float deltaTime) + { + var targetDraft = CalculateRawDraftDepth(); + _currentDraftDepth = Mathf.SmoothDamp( + _currentDraftDepth, + targetDraft, + ref _draftVelocity, + Mathf.Max(0.0001f, draftSmoothTime), + Mathf.Infinity, + deltaTime); + } + + private void UpdateVerticalPosition(float deltaTime) + { + var position = transform.position; + var targetY = waterLevel - _currentDraftDepth - bottomOffsetLocalY + _biteOffsetY; + + if (Mathf.Abs(position.y - targetY) < yDeadZone) + { + position.y = targetY; + _ySmoothVelocity = 0f; + } + else + { + position.y = Mathf.SmoothDamp( + position.y, + targetY, + ref _ySmoothVelocity, + Mathf.Max(0.0001f, ySmoothTime), + maxYSpeed, + deltaTime); + } + + transform.position = position; + + var velocity = _rb.linearVelocity; + if (Mathf.Abs(velocity.y) > 0f) + { + velocity.y = 0f; + _rb.linearVelocity = velocity; + } + } + + private void UpdateRotation(float deltaTime) + { + var targetTiltAngle = EvaluateTargetTiltAngle(); + var signedAngle = invertTiltDirection ? -targetTiltAngle : targetTiltAngle; + var localAxis = tiltAxis == BobberTiltAxis.LocalX ? Vector3.right : Vector3.forward; + + _targetRotation = _uprightReferenceRotation * Quaternion.AngleAxis(signedAngle, localAxis); + _rb.angularVelocity = Vector3.zero; + transform.rotation = Quaternion.Slerp( + transform.rotation, + _targetRotation, + 1f - Mathf.Exp(-Mathf.Max(0.01f, rotationLerpSpeed) * deltaTime)); + } + + private float EvaluateTargetTiltAngle() + { + if (currentBottomWeight <= lyingWeightThreshold) + { + return lyingAngle; + } + + if (currentBottomWeight <= tiltedWeightThreshold) + { + var t = Mathf.InverseLerp(lyingWeightThreshold, tiltedWeightThreshold, currentBottomWeight); + return Mathf.Lerp(lyingAngle, tiltedAngle, t); + } + + if (currentBottomWeight <= uprightWeightThreshold) + { + var t = Mathf.InverseLerp(tiltedWeightThreshold, uprightWeightThreshold, currentBottomWeight); + return Mathf.Lerp(tiltedAngle, uprightAngle, t); + } + + return uprightAngle; + } + + private void StartBite(FishingBobberBiteType type, float amplitude, float duration) + { + if (_mode != FishingBobberControlMode.WaterPresentation) + { + return; + } + + _activeBiteType = type; + _biteTimer = 0f; + _biteDuration = Mathf.Max(0.01f, duration); + _biteAmplitude = Mathf.Max(0f, amplitude); + _biteOffsetYVelocity = 0f; + } + + private void UpdateBiteAnimation(float deltaTime) + { + if (_activeBiteType == FishingBobberBiteType.None) + { + _biteOffsetY = Mathf.SmoothDamp( + _biteOffsetY, + 0f, + ref _biteOffsetYVelocity, + Mathf.Max(0.0001f, biteSmoothTime), + Mathf.Infinity, + deltaTime); + return; + } + + _biteTimer += deltaTime; + var t = Mathf.Clamp01(_biteTimer / _biteDuration); + var targetOffset = 0f; + + switch (_activeBiteType) + { + case FishingBobberBiteType.Tap: + if (t < 0.35f) + { + var downT = t / 0.35f; + targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, downT); + } + else + { + var upT = (t - 0.35f) / 0.65f; + targetOffset = -Mathf.Lerp(_biteAmplitude, 0f, upT); + } + break; + + case FishingBobberBiteType.SlowSink: + targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, t); + break; + + case FishingBobberBiteType.Lift: + targetOffset = Mathf.SmoothStep(0f, _biteAmplitude, t); + break; + + case FishingBobberBiteType.BlackDrift: + targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, t); + break; + } + + _biteOffsetY = Mathf.SmoothDamp( + _biteOffsetY, + targetOffset, + ref _biteOffsetYVelocity, + Mathf.Max(0.0001f, biteSmoothTime), + Mathf.Infinity, + deltaTime); + + if (_biteTimer >= _biteDuration && + _activeBiteType != FishingBobberBiteType.SlowSink && + _activeBiteType != FishingBobberBiteType.BlackDrift) + { + _activeBiteType = FishingBobberBiteType.None; + } + } + + private void HandleDebugInput() + { + if (!Application.isPlaying || !enableDebugInput) + { + return; + } + + if (Input.GetKeyDown(stopBiteKey)) + { + StopBite(); + } + + if (Input.GetKeyDown(tapKey)) + { + PlayTap(); + } + + if (Input.GetKeyDown(slowSinkKey)) + { + PlaySlowSink(); + } + + if (Input.GetKeyDown(liftKey)) + { + PlayLift(); + } + + if (Input.GetKeyDown(blackDriftKey)) + { + PlayBlackDrift(); + } + } + + #endregion + + #region 参数校验 + +#if UNITY_EDITOR + private void OnValidate() + { + floatHeight = Mathf.Max(0.001f, floatHeight); + ySmoothTime = Mathf.Max(0.001f, ySmoothTime); + maxYSpeed = Mathf.Max(0.01f, maxYSpeed); + yDeadZone = Mathf.Max(0f, yDeadZone); + + neutralBottomWeight = Mathf.Max(0f, neutralBottomWeight); + currentBottomWeight = Mathf.Max(0f, currentBottomWeight); + minDraftDepth = Mathf.Max(0f, minDraftDepth); + maxDraftDepth = Mathf.Max(minDraftDepth, maxDraftDepth); + baseDraftDepth = Mathf.Clamp(baseDraftDepth, minDraftDepth, maxDraftDepth); + draftDepthPerWeight = Mathf.Max(0f, draftDepthPerWeight); + draftSmoothTime = Mathf.Max(0.001f, draftSmoothTime); + + biteSmoothTime = Mathf.Max(0.001f, biteSmoothTime); + tapAmplitude = Mathf.Max(0f, tapAmplitude); + tapDuration = Mathf.Max(0.01f, tapDuration); + slowSinkAmplitude = Mathf.Max(0f, slowSinkAmplitude); + slowSinkDuration = Mathf.Max(0.01f, slowSinkDuration); + liftAmplitude = Mathf.Max(0f, liftAmplitude); + liftDuration = Mathf.Max(0.01f, liftDuration); + blackDriftAmplitude = Mathf.Max(0f, blackDriftAmplitude); + blackDriftDuration = Mathf.Max(0.01f, blackDriftDuration); + + lyingWeightThreshold = Mathf.Max(0f, lyingWeightThreshold); + tiltedWeightThreshold = Mathf.Max(lyingWeightThreshold, tiltedWeightThreshold); + uprightWeightThreshold = Mathf.Max(tiltedWeightThreshold, uprightWeightThreshold); + uprightAngle = Mathf.Clamp(uprightAngle, 0f, 89f); + tiltedAngle = Mathf.Clamp(tiltedAngle, uprightAngle, 89f); + lyingAngle = Mathf.Clamp(lyingAngle, tiltedAngle, 89.9f); + rotationLerpSpeed = Mathf.Max(0.01f, rotationLerpSpeed); + waterAngularDamping = Mathf.Max(0f, waterAngularDamping); + } +#endif + + #endregion + } +} diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingBobberFeature.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingBobberFeature.cs.meta new file mode 100644 index 000000000..b251a45db --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingBobberFeature.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ca4d5d54d89446b0a10b7ce521fd7d9e +timeCreated: 1775958532 diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingDefaultPhysicsFeature.cs b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingDefaultPhysicsFeature.cs new file mode 100644 index 000000000..824664b94 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingDefaultPhysicsFeature.cs @@ -0,0 +1,46 @@ +using UnityEngine; + +namespace NBF +{ + /// + /// 默认物理组件 + /// + public class FishingDefaultPhysicsFeature : FishingLineNodeMotionFeature + { + [Header("Physics")] [SerializeField] private bool useGravity = true; + + protected override int DefaultPriority => 0; + + public override bool IsSupportedNode(FishingLineNode node) + { + return node != null + && !node.IsRuntimeVirtualNode + && node.Type != FishingLineNode.NodeType.Start; + } + + public override bool CanControl() + { + return Node != null && Node.Body != null && IsSupportedNode(Node); + } + + public override void OnMotionActivated() + { + ApplyPhysicsState(); + } + + public override void TickMotion(float deltaTime) + { + ApplyPhysicsState(); + } + + private void ApplyPhysicsState() + { + if (Node == null || Node.Body == null || !IsSupportedNode(Node)) + { + return; + } + + Node.Body.useGravity = useGravity; + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingDefaultPhysicsFeature.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingDefaultPhysicsFeature.cs.meta new file mode 100644 index 000000000..8dfbf6598 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingDefaultPhysicsFeature.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 2ebfa4366b504ba0a3f398eded17df31 +timeCreated: 1775957743 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingFloatFeature.cs b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingFloatFeature.cs new file mode 100644 index 000000000..dbdd6855a --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingFloatFeature.cs @@ -0,0 +1,944 @@ +using UnityEngine; + +namespace NBF +{ + public interface IWaterSurfaceProvider + { + float GetWaterHeight(Vector3 worldPos); + Vector3 GetWaterNormal(Vector3 worldPos); + } + + public enum BobberControlMode + { + AirPhysics, + WaterPresentation, + } + + public enum BobberBiteType + { + None, + Tap, + SlowSink, + Lift, + BlackDrift, + } + + public enum BobberPosture + { + Lying, + Tilted, + Upright, + } + + [DisallowMultipleComponent] + [RequireComponent(typeof(Rigidbody))] + public class FishingFloatFeature : FishingLineNodeMotionFeature + { + protected override int DefaultPriority => 100; + + [Header("Water")] + [Tooltip("没有水提供器时使用固定水位")] + public float fallbackWaterLevel; + + [Tooltip("可选:挂实现了 IWaterSurfaceProvider 的组件")] + public MonoBehaviour waterProviderBehaviour; + + [Header("Enter Water")] + [Tooltip("底部进入水面多少米后切换为漂像控制")] + public float enterWaterDepth = 0.002f; + + [Tooltip("离开水面多少米后回到空中物理。一般给负值做滞回")] + public float exitWaterDepth = -0.01f; + + [Header("Geometry")] + [Tooltip("浮漂总高度(米)")] + public float floatHeight = 0.08f; + + [Tooltip("如果 Pivot 在浮漂底部,这里填 0;如果 Pivot 在模型中心,就填底部相对 Pivot 的本地 Y")] + public float bottomOffsetLocalY; + + [Header("Base Float")] + [Tooltip("基础吃铅比例,决定静止时有多少在水下")] + [Range(0.05f, 0.95f)] + public float baseSubmergeRatio = 0.28f; + + [Tooltip("Y 轴平滑时间,越小响应越快")] + public float ySmoothTime = 0.08f; + + [Tooltip("最大竖直速度限制(用于 SmoothDamp)")] + public float maxYSpeed = 2f; + + [Tooltip("静止小死区,减少微抖")] + public float yDeadZone = 0.0005f; + + [Header("Surface Motion")] + [Tooltip("是否启用轻微水面起伏")] + public bool enableSurfaceBobbing = true; + + [Tooltip("水面轻微起伏振幅(米)")] + public float surfaceBobAmplitude = 0.0015f; + + [Tooltip("水面轻微起伏频率")] + public float surfaceBobFrequency = 1.2f; + + [Header("XZ Motion")] + [Tooltip("入水后是否锁定 XZ 到入水点附近")] + public bool lockXZAroundAnchor = true; + + [Tooltip("XZ 跟随平滑时间")] + public float xzSmoothTime = 0.15f; + + [Tooltip("水流/拖拽带来的额外平面偏移最大值")] + public float maxPlanarOffset = 0.15f; + + [Header("Sink By Weight / Tension")] + [Tooltip("外部向下拉力映射为下沉量的系数。你可以把钩/铅/线组的等效向下拉力喂进来")] + public float downForceToSink = 0.0025f; + + [Tooltip("向下拉力下沉的最大附加量")] + public float maxExtraSink = 0.08f; + + [Header("Bottom Touch")] + [Tooltip("触底时是否启用修正")] + public bool enableBottomTouchAdjust = true; + + [Tooltip("触底后减少的下沉量(例如铅坠到底,漂会回升一点)")] + public float bottomTouchLift = 0.01f; + + [Header("Posture Source")] + [Tooltip("下方 Lure / 钩组 / 铅坠的刚体。姿态主要根据它和浮漂的相对位置判断")] + public Rigidbody lureBody; + + [Tooltip("用于归一化的参考长度。一般填:浮漂到 Lure 在“正常拉直”时的大致长度")] + public float referenceLength = 0.30f; + + [Header("Posture Threshold")] + [Tooltip("最小入水比例。不够时优先躺漂")] + public float minSubmergeToStand = 0.16f; + + [Tooltip("垂直分量比低于该值时,优先躺漂")] + public float verticalLieThreshold = 0.18f; + + [Tooltip("垂直分量比高于该值,且水平分量较小时,允许立漂")] + public float verticalUprightThreshold = 0.75f; + + [Tooltip("水平分量比高于该值时,不允许完全立漂")] + public float planarTiltThreshold = 0.30f; + + [Tooltip("水平分量明显大于垂直分量时,优先躺漂")] + public float planarDominanceMultiplier = 1.20f; + + [Tooltip("姿态切换滞回")] + public float postureHysteresis = 0.04f; + + [Header("Posture Stability")] + [Tooltip("候选姿态需持续多久才真正切换")] + public float postureConfirmTime = 0.08f; + + [Tooltip("姿态切换后的最短冷却时间,避免来回闪烁")] + public float postureSwitchCooldown = 0.10f; + + [Header("Posture Rotation")] + [Tooltip("倾斜状态角度")] + public float tiltedAngle = 38f; + + [Tooltip("躺漂角度")] + public float lyingAngle = 88f; + + [Tooltip("立漂时允许的最大附加倾角")] + public float uprightMaxTiltAngle = 8f; + + [Tooltip("平面方向对立漂/斜漂附加倾角的影响强度")] + public float planarTiltFactor = 120f; + + [Tooltip("平面方向死区,小于该值时保持上一帧方向")] + public float planarDirectionDeadZone = 0.01f; + + [Tooltip("平面方向平滑速度")] + public float planarDirectionLerpSpeed = 10f; + + [Tooltip("姿态平滑速度")] + public float rotationLerpSpeed = 8f; + + [Header("Debug Input")] + public bool debugResetKey = true; + public bool debugTapKey = true; + public bool debugSlowSinkKey = true; + public bool debugLiftKey = true; + public bool debugBlackDriftKey = true; + + [Header("Debug")] + public bool drawDebug; + + public bool UseTestPosture; + public BobberPosture TestPosture; + + public BobberControlMode CurrentMode => _mode; + public BobberPosture CurrentPosture => _posture; + public float CurrentVerticalRatio => _verticalRatio; + public float CurrentPlanarRatio => _planarRatio; + + public float ExternalDownForce { get; set; } + public bool IsBottomTouched { get; set; } + public Vector2 ExternalPlanarOffset { get; set; } + + private Rigidbody _rb; + private IWaterSurfaceProvider _waterProvider; + private BobberControlMode _mode = BobberControlMode.AirPhysics; + private BobberPosture _posture = BobberPosture.Lying; + + private float _defaultLinearDamping; + private float _defaultAngularDamping; + private bool _defaultUseGravity; + private bool _defaultsCached; + + private Vector3 _waterAnchorPos; + private Vector3 _xzSmoothVelocity; + private float _ySmoothVelocity; + private float _biteOffsetY; + private float _biteOffsetYVelocity; + private Quaternion _targetRotation; + + private BobberBiteType _activeBiteType = BobberBiteType.None; + private float _biteTimer; + private float _biteDuration; + private float _biteAmplitude; + private Vector3 _blackDriftDirection; + + private float _verticalRatio; + private float _planarRatio; + private float _verticalDistance; + private float _planarDistance; + private BobberPosture _pendingPosture; + private float _pendingPostureTimer; + private float _postureCooldownTimer; + private Vector3 _stablePlanarDir = Vector3.forward; + + private void Awake() + { + EnsureRuntimeReferences(); + InitializeRuntimeState(); + } + + private void Update() + { + HandleDebugKeys(); + } + + public override bool IsSupportedNode(FishingLineNode node) + { + return node != null && node.Type == FishingLineNode.NodeType.Float; + } + + protected override void OnBind() + { + EnsureRuntimeReferences(); + InitializeRuntimeState(); + } + + public override bool CanControl() + { + EnsureRuntimeReferences(); + if (_rb == null || !IsSupportedNode(Node)) + { + return false; + } + + var waterY = GetWaterHeight(transform.position); + var submergeDepth = waterY - GetBottomWorldPosition().y; + if (_mode == BobberControlMode.WaterPresentation) + { + return submergeDepth >= exitWaterDepth; + } + + return submergeDepth > enterWaterDepth; + } + + public override void OnMotionActivated() + { + EnsureRuntimeReferences(); + EnterWaterPresentationMode(); + } + + public override void OnMotionDeactivated() + { + EnsureRuntimeReferences(); + ExitWaterPresentationMode(); + } + + public override void TickMotion(float deltaTime) + { + EnsureRuntimeReferences(); + if (_rb == null) + { + return; + } + + var waterY = GetWaterHeight(transform.position); + var submergeDepth = waterY - GetBottomWorldPosition().y; + UpdateWaterPresentation(waterY, submergeDepth, deltaTime); + + if (drawDebug) + { + DrawDebug(waterY); + } + } + + public void PlayTap(float amplitude = 0.008f, float duration = 0.18f) + { + StartBite(BobberBiteType.Tap, amplitude, duration); + } + + public void PlaySlowSink(float amplitude = 0.025f, float duration = 1.2f) + { + StartBite(BobberBiteType.SlowSink, amplitude, duration); + } + + public void PlayLift(float amplitude = 0.015f, float duration = 1.2f) + { + StartBite(BobberBiteType.Lift, amplitude, duration); + } + + public void PlayBlackDrift(float amplitude = 0.06f, float duration = 0.8f, Vector3? driftDirection = null) + { + StartBite(BobberBiteType.BlackDrift, amplitude, duration); + _blackDriftDirection = (driftDirection ?? transform.forward).normalized; + } + + public void StopBite() + { + _activeBiteType = BobberBiteType.None; + _biteTimer = 0f; + _biteDuration = 0f; + _biteAmplitude = 0f; + _biteOffsetY = 0f; + _biteOffsetYVelocity = 0f; + } + + private void EnsureRuntimeReferences() + { + if (_rb == null) + { + _rb = Node != null && Node.Body != null ? Node.Body : GetComponent(); + } + + if (_waterProvider == null && waterProviderBehaviour != null) + { + _waterProvider = waterProviderBehaviour as IWaterSurfaceProvider; + } + + } + + private void InitializeRuntimeState() + { + if (_rb == null) + { + return; + } + + if (!_defaultsCached) + { + _defaultLinearDamping = _rb.linearDamping; + _defaultAngularDamping = _rb.angularDamping; + _defaultUseGravity = _rb.useGravity; + _defaultsCached = true; + } + + _pendingPosture = _posture; + _pendingPostureTimer = 0f; + _postureCooldownTimer = 0f; + _stablePlanarDir = Vector3.ProjectOnPlane(transform.forward, Vector3.up); + if (_stablePlanarDir.sqrMagnitude < 1e-6f) + { + _stablePlanarDir = Vector3.forward; + } + else + { + _stablePlanarDir.Normalize(); + } + + _targetRotation = transform.rotation; + } + + private void UpdateWaterPresentation(float waterY, float submergeDepth, float deltaTime) + { + if (submergeDepth < exitWaterDepth) + { + ExitWaterPresentationMode(); + return; + } + + _rb.useGravity = false; + _rb.linearVelocity = Vector3.zero; + _rb.angularVelocity = Vector3.zero; + _rb.linearDamping = 999f; + _rb.angularDamping = 999f; + + UpdateBiteAnimation(deltaTime); + + var pos = transform.position; + var targetY = CalculateTargetY(waterY); + if (Mathf.Abs(pos.y - targetY) < yDeadZone) + { + pos.y = targetY; + _ySmoothVelocity = 0f; + } + else + { + pos.y = Mathf.SmoothDamp( + pos.y, + targetY, + ref _ySmoothVelocity, + Mathf.Max(0.0001f, ySmoothTime), + maxYSpeed, + deltaTime); + } + + var targetXZ = CalculateTargetXZ(); + var planarPos = new Vector3(pos.x, 0f, pos.z); + var planarTarget = new Vector3(targetXZ.x, 0f, targetXZ.z); + planarPos = Vector3.SmoothDamp( + planarPos, + planarTarget, + ref _xzSmoothVelocity, + Mathf.Max(0.0001f, xzSmoothTime), + Mathf.Infinity, + deltaTime); + + pos.x = planarPos.x; + pos.z = planarPos.z; + transform.position = pos; + + EvaluatePostureByComponents(waterY, deltaTime); + UpdateTargetRotationByPosture(deltaTime); + + transform.rotation = Quaternion.Slerp( + transform.rotation, + _targetRotation, + 1f - Mathf.Exp(-rotationLerpSpeed * deltaTime)); + } + + private void EnterWaterPresentationMode() + { + if (_rb == null) + { + return; + } + + _mode = BobberControlMode.WaterPresentation; + _waterAnchorPos = transform.position; + _ySmoothVelocity = 0f; + _xzSmoothVelocity = Vector3.zero; + _biteOffsetY = 0f; + _biteOffsetYVelocity = 0f; + _activeBiteType = BobberBiteType.None; + _biteTimer = 0f; + + _posture = BobberPosture.Lying; + _verticalRatio = 0f; + _planarRatio = 0f; + _verticalDistance = 0f; + _planarDistance = 0f; + _pendingPosture = _posture; + _pendingPostureTimer = 0f; + _postureCooldownTimer = 0f; + _stablePlanarDir = Vector3.ProjectOnPlane(transform.forward, Vector3.up); + if (_stablePlanarDir.sqrMagnitude < 1e-6f) + { + _stablePlanarDir = Vector3.forward; + } + else + { + _stablePlanarDir.Normalize(); + } + + _rb.useGravity = false; + _rb.linearVelocity = Vector3.zero; + _rb.angularVelocity = Vector3.zero; + _rb.linearDamping = 999f; + _rb.angularDamping = 999f; + } + + private void ExitWaterPresentationMode() + { + _mode = BobberControlMode.AirPhysics; + RestoreAirPhysicsState(); + } + + private void RestoreAirPhysicsState() + { + if (_rb == null || !_defaultsCached) + { + return; + } + + _rb.useGravity = _defaultUseGravity; + _rb.linearDamping = _defaultLinearDamping; + _rb.angularDamping = _defaultAngularDamping; + } + + private float CalculateTargetY(float waterY) + { + var baseSinkDepth = floatHeight * Mathf.Clamp01(baseSubmergeRatio); + var sinkByForce = Mathf.Clamp(ExternalDownForce * downForceToSink, 0f, maxExtraSink); + + var bottomAdjust = 0f; + if (enableBottomTouchAdjust && IsBottomTouched) + { + bottomAdjust -= bottomTouchLift; + } + + var surfaceBob = 0f; + if (enableSurfaceBobbing) + { + surfaceBob = Mathf.Sin(Time.time * surfaceBobFrequency * Mathf.PI * 2f) * surfaceBobAmplitude; + } + + var totalSink = baseSinkDepth + sinkByForce + bottomAdjust; + var targetBottomY = waterY - totalSink; + return targetBottomY - bottomOffsetLocalY + surfaceBob + _biteOffsetY; + } + + private Vector3 CalculateTargetXZ() + { + var planarOffset = Vector2.ClampMagnitude(ExternalPlanarOffset, maxPlanarOffset); + var basePos = lockXZAroundAnchor ? _waterAnchorPos : transform.position; + + if (_activeBiteType == BobberBiteType.BlackDrift) + { + var t = Mathf.Clamp01(_biteDuration > 0f ? _biteTimer / _biteDuration : 1f); + var drift = Mathf.SmoothStep(0f, 1f, t) * 0.08f; + var blackDrift = _blackDriftDirection * drift; + basePos += new Vector3(blackDrift.x, 0f, blackDrift.z); + } + + return new Vector3( + basePos.x + planarOffset.x, + transform.position.y, + basePos.z + planarOffset.y); + } + + private void EvaluatePostureByComponents(float waterY, float deltaTime) + { + var submergeRatio = Mathf.Clamp01( + (waterY - GetBottomWorldPosition().y) / Mathf.Max(0.0001f, floatHeight)); + + var hasLure = lureBody != null; + if (!hasLure) + { + _verticalDistance = 0f; + _planarDistance = 0f; + _verticalRatio = 0f; + _planarRatio = 0f; + } + else + { + var bobberPos = _rb.worldCenterOfMass; + var lurePos = lureBody.worldCenterOfMass; + var delta = lurePos - bobberPos; + + _verticalDistance = Mathf.Max(0f, Vector3.Dot(delta, Vector3.down)); + _planarDistance = Vector3.ProjectOnPlane(delta, Vector3.up).magnitude; + + var refLen = Mathf.Max(0.0001f, referenceLength); + _verticalRatio = _verticalDistance / refLen; + _planarRatio = _planarDistance / refLen; + } + + var desiredPosture = DeterminePostureState(submergeRatio, hasLure); + ApplyPostureWithStability(desiredPosture, deltaTime); + } + + private BobberPosture DeterminePostureState(float submergeRatio, bool hasLure) + { + if (UseTestPosture) + { + return TestPosture; + } + + if (!hasLure) + { + if (submergeRatio < minSubmergeToStand) + { + return BobberPosture.Lying; + } + + if (ExternalPlanarOffset.magnitude > 0.01f) + { + return BobberPosture.Tilted; + } + + return BobberPosture.Upright; + } + + switch (_posture) + { + case BobberPosture.Lying: + { + var canStandUpright = + submergeRatio >= minSubmergeToStand && + _verticalRatio > verticalUprightThreshold + postureHysteresis && + _planarRatio < planarTiltThreshold - postureHysteresis; + + var canTilt = + submergeRatio >= minSubmergeToStand * 0.8f && + _verticalRatio > verticalLieThreshold + postureHysteresis; + + if (canStandUpright) + { + return BobberPosture.Upright; + } + + if (canTilt) + { + return BobberPosture.Tilted; + } + + return BobberPosture.Lying; + } + + case BobberPosture.Tilted: + { + var shouldLie = + submergeRatio < minSubmergeToStand * 0.75f || + _verticalRatio < verticalLieThreshold - postureHysteresis || + _planarDistance > _verticalDistance * planarDominanceMultiplier; + + var shouldStand = + submergeRatio >= minSubmergeToStand && + _verticalRatio > verticalUprightThreshold + postureHysteresis && + _planarRatio < planarTiltThreshold - postureHysteresis; + + if (shouldLie) + { + return BobberPosture.Lying; + } + + if (shouldStand) + { + return BobberPosture.Upright; + } + + return BobberPosture.Tilted; + } + + default: + { + var shouldLie = + submergeRatio < minSubmergeToStand * 0.75f || + _verticalRatio < verticalLieThreshold - postureHysteresis || + _planarDistance > _verticalDistance * (planarDominanceMultiplier + 0.15f); + + var shouldTilt = + _verticalRatio < verticalUprightThreshold - postureHysteresis || + _planarRatio > planarTiltThreshold + postureHysteresis; + + if (shouldLie) + { + return BobberPosture.Lying; + } + + if (shouldTilt) + { + return BobberPosture.Tilted; + } + + return BobberPosture.Upright; + } + } + } + + private void ApplyPostureWithStability(BobberPosture desiredPosture, float deltaTime) + { + _postureCooldownTimer = Mathf.Max(0f, _postureCooldownTimer - deltaTime); + if (desiredPosture == _posture) + { + _pendingPosture = _posture; + _pendingPostureTimer = 0f; + return; + } + + if (_postureCooldownTimer > 0f) + { + _pendingPosture = desiredPosture; + _pendingPostureTimer = 0f; + return; + } + + if (_pendingPosture != desiredPosture) + { + _pendingPosture = desiredPosture; + _pendingPostureTimer = 0f; + return; + } + + _pendingPostureTimer += deltaTime; + if (_pendingPostureTimer >= Mathf.Max(0f, postureConfirmTime)) + { + _posture = desiredPosture; + _pendingPosture = _posture; + _pendingPostureTimer = 0f; + _postureCooldownTimer = Mathf.Max(0f, postureSwitchCooldown); + } + } + + private void UpdateTargetRotationByPosture(float deltaTime) + { + var candidateDir = Vector3.zero; + if (lureBody != null) + { + var delta = lureBody.worldCenterOfMass - _rb.worldCenterOfMass; + candidateDir = Vector3.ProjectOnPlane(delta, Vector3.up); + } + + if (candidateDir.sqrMagnitude < 1e-6f) + { + candidateDir = new Vector3(_xzSmoothVelocity.x, 0f, _xzSmoothVelocity.z); + } + + if (candidateDir.sqrMagnitude < 1e-6f) + { + candidateDir = new Vector3(ExternalPlanarOffset.x, 0f, ExternalPlanarOffset.y); + } + + if (_stablePlanarDir.sqrMagnitude < 1e-6f) + { + _stablePlanarDir = Vector3.ProjectOnPlane(transform.forward, Vector3.up); + if (_stablePlanarDir.sqrMagnitude < 1e-6f) + { + _stablePlanarDir = Vector3.forward; + } + } + + _stablePlanarDir.Normalize(); + + var dirDeadZone = Mathf.Max(0.0001f, planarDirectionDeadZone); + if (candidateDir.sqrMagnitude > dirDeadZone * dirDeadZone) + { + candidateDir.Normalize(); + if (Vector3.Dot(candidateDir, _stablePlanarDir) < 0f) + { + candidateDir = -candidateDir; + } + + var k = 1f - Mathf.Exp(-Mathf.Max(0.01f, planarDirectionLerpSpeed) * deltaTime); + _stablePlanarDir = Vector3.Slerp(_stablePlanarDir, candidateDir, k); + _stablePlanarDir.Normalize(); + } + + var planarDir = _stablePlanarDir; + var tiltAxis = Vector3.Cross(Vector3.up, planarDir); + if (tiltAxis.sqrMagnitude < 1e-6f) + { + tiltAxis = transform.right; + } + + var angle = _posture switch + { + BobberPosture.Lying => lyingAngle, + BobberPosture.Tilted => tiltedAngle, + _ => 0f, + }; + + _targetRotation = Quaternion.AngleAxis(angle, tiltAxis.normalized); + } + + private void StartBite(BobberBiteType type, float amplitude, float duration) + { + if (_mode != BobberControlMode.WaterPresentation) + { + return; + } + + _activeBiteType = type; + _biteTimer = 0f; + _biteDuration = Mathf.Max(0.01f, duration); + _biteAmplitude = amplitude; + _biteOffsetYVelocity = 0f; + + if (type == BobberBiteType.BlackDrift && _blackDriftDirection.sqrMagnitude < 1e-6f) + { + _blackDriftDirection = + transform.forward.sqrMagnitude > 1e-6f ? transform.forward.normalized : Vector3.forward; + } + } + + private void UpdateBiteAnimation(float deltaTime) + { + if (_activeBiteType == BobberBiteType.None) + { + _biteOffsetY = Mathf.SmoothDamp( + _biteOffsetY, + 0f, + ref _biteOffsetYVelocity, + 0.08f, + Mathf.Infinity, + deltaTime); + return; + } + + _biteTimer += deltaTime; + var t = Mathf.Clamp01(_biteTimer / _biteDuration); + var targetOffset = 0f; + + switch (_activeBiteType) + { + case BobberBiteType.Tap: + if (t < 0.35f) + { + var k = t / 0.35f; + targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, k); + } + else + { + var k = (t - 0.35f) / 0.65f; + targetOffset = -Mathf.Lerp(_biteAmplitude, 0f, k); + } + break; + + case BobberBiteType.SlowSink: + targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, t); + break; + + case BobberBiteType.Lift: + targetOffset = Mathf.SmoothStep(0f, _biteAmplitude, t); + break; + + case BobberBiteType.BlackDrift: + targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, t); + break; + } + + _biteOffsetY = Mathf.SmoothDamp( + _biteOffsetY, + targetOffset, + ref _biteOffsetYVelocity, + 0.03f, + Mathf.Infinity, + deltaTime); + + if (_biteTimer >= _biteDuration && + _activeBiteType != BobberBiteType.SlowSink && + _activeBiteType != BobberBiteType.BlackDrift) + { + _activeBiteType = BobberBiteType.None; + } + } + + private float GetWaterHeight(Vector3 worldPos) + { + if (_waterProvider != null) + { + return _waterProvider.GetWaterHeight(worldPos); + } + + return fallbackWaterLevel; + } + + private Vector3 GetBottomWorldPosition() + { + return transform.TransformPoint(new Vector3(0f, bottomOffsetLocalY, 0f)); + } + + private void HandleDebugKeys() + { + if (!Application.isPlaying) + { + return; + } + + if (debugResetKey && Input.GetKeyDown(KeyCode.R)) + { + StopBite(); + } + + if (debugTapKey && Input.GetKeyDown(KeyCode.T)) + { + PlayTap(); + } + + if (debugSlowSinkKey && Input.GetKeyDown(KeyCode.G)) + { + PlaySlowSink(); + } + + if (debugLiftKey && Input.GetKeyDown(KeyCode.H)) + { + PlayLift(); + } + + if (debugBlackDriftKey && Input.GetKeyDown(KeyCode.B)) + { + PlayBlackDrift(); + } + } + + private void DrawDebug(float waterY) + { + var p = transform.position; + var b = GetBottomWorldPosition(); + + Debug.DrawLine( + new Vector3(p.x - 0.05f, waterY, p.z), + new Vector3(p.x + 0.05f, waterY, p.z), + Color.cyan); + + Debug.DrawLine(b, b + Vector3.up * floatHeight, Color.yellow); + + if (_mode == BobberControlMode.WaterPresentation) + { + var a = _waterAnchorPos; + Debug.DrawLine(a + Vector3.left * 0.03f, a + Vector3.right * 0.03f, Color.green); + Debug.DrawLine(a + Vector3.forward * 0.03f, a + Vector3.back * 0.03f, Color.green); + } + + if (lureBody != null) + { + var bobber = _rb.worldCenterOfMass; + var lure = lureBody.worldCenterOfMass; + Debug.DrawLine(bobber, lure, Color.magenta); + + var verticalEnd = bobber + Vector3.down * _verticalDistance; + Debug.DrawLine(bobber, verticalEnd, Color.red); + + var planar = Vector3.ProjectOnPlane(lure - bobber, Vector3.up); + Debug.DrawLine(verticalEnd, verticalEnd + planar, Color.blue); + } + } + +#if UNITY_EDITOR + private void OnValidate() + { + floatHeight = Mathf.Max(0.001f, floatHeight); + ySmoothTime = Mathf.Max(0.001f, ySmoothTime); + maxYSpeed = Mathf.Max(0.01f, maxYSpeed); + xzSmoothTime = Mathf.Max(0.001f, xzSmoothTime); + rotationLerpSpeed = Mathf.Max(0.01f, rotationLerpSpeed); + + maxPlanarOffset = Mathf.Max(0f, maxPlanarOffset); + downForceToSink = Mathf.Max(0f, downForceToSink); + maxExtraSink = Mathf.Max(0f, maxExtraSink); + surfaceBobAmplitude = Mathf.Max(0f, surfaceBobAmplitude); + surfaceBobFrequency = Mathf.Max(0f, surfaceBobFrequency); + yDeadZone = Mathf.Max(0f, yDeadZone); + + referenceLength = Mathf.Max(0.0001f, referenceLength); + minSubmergeToStand = Mathf.Clamp01(minSubmergeToStand); + verticalLieThreshold = Mathf.Clamp(verticalLieThreshold, 0f, 2f); + verticalUprightThreshold = Mathf.Max(verticalLieThreshold, verticalUprightThreshold); + planarTiltThreshold = Mathf.Clamp(planarTiltThreshold, 0f, 2f); + planarDominanceMultiplier = Mathf.Max(0.1f, planarDominanceMultiplier); + postureHysteresis = Mathf.Clamp(postureHysteresis, 0f, 0.3f); + postureConfirmTime = Mathf.Max(0f, postureConfirmTime); + postureSwitchCooldown = Mathf.Max(0f, postureSwitchCooldown); + + tiltedAngle = Mathf.Clamp(tiltedAngle, 0f, 89f); + lyingAngle = Mathf.Clamp(lyingAngle, tiltedAngle, 89.9f); + uprightMaxTiltAngle = Mathf.Clamp(uprightMaxTiltAngle, 0f, tiltedAngle); + planarTiltFactor = Mathf.Max(0f, planarTiltFactor); + planarDirectionDeadZone = Mathf.Max(0.0001f, planarDirectionDeadZone); + planarDirectionLerpSpeed = Mathf.Max(0.01f, planarDirectionLerpSpeed); + } +#endif + } +} diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingFloatFeature.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingFloatFeature.cs.meta new file mode 100644 index 000000000..495e4da99 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingFloatFeature.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 40a38940e81046e2854add979cedbef9 +timeCreated: 1775958531 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeFeature.cs b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeFeature.cs new file mode 100644 index 000000000..5579db5ef --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeFeature.cs @@ -0,0 +1,63 @@ +using UnityEngine; + +namespace NBF +{ + public abstract class FishingLineNodeFeature : MonoBehaviour + { + /// + /// 当前功能组件所属的节点。 + /// + public FishingLineNode Node { get; private set; } + + /// + /// 当前功能组件所属的鱼线求解器。 + /// + public FishingLineSolver Solver { get; private set; } + + /// + /// 将当前功能组件绑定到指定节点和求解器。 + /// + public void Bind(FishingLineNode node, FishingLineSolver solver) + { + Node = node; + Solver = solver; + + if (!IsSupportedNode(node)) + { + Debug.LogWarning($"{GetType().Name} 不适用于节点 {node.name} 的当前配置。", this); + } + + OnBind(); + } + + /// + /// 当前功能组件是否支持挂在该节点上。 + /// 子类可按节点类型、尾节点类型或产品标识做限制。 + /// + public virtual bool IsSupportedNode(FishingLineNode node) + { + return node != null; + } + + /// + /// 节点与求解器绑定完成后的回调。 + /// + protected virtual void OnBind() + { + } + + /// + /// 鱼线链路重建完成后的回调。 + /// + public virtual void OnLineBuilt() + { + } + + /// + /// 鱼线达到断线条件后的回调。 + /// + public virtual void OnLineBreakRequested() + { + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeFeature.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeFeature.cs.meta new file mode 100644 index 000000000..bbf16c78e --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeFeature.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c7ea70945db841deb0ee8df85f0e15ec +timeCreated: 1775957663 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeMotionFeature.cs b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeMotionFeature.cs new file mode 100644 index 000000000..12a12dee9 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeMotionFeature.cs @@ -0,0 +1,47 @@ +using UnityEngine; + +namespace NBF +{ + public abstract class FishingLineNodeMotionFeature : FishingLineNodeFeature + { + [Header("Motion Control")] [SerializeField] + private int priorityOffset; + + /// + /// 当前运动控制组件的优先级。 + /// 值越大,越容易取得节点运动控制权。 + /// 最终优先级 = 默认优先级 + 调整值。 + /// + public int Priority => DefaultPriority + priorityOffset; + + /// + /// 当前运动控制组件的默认优先级。 + /// 子类可通过重写该值,决定自己相对默认物理的抢占能力。 + /// + protected virtual int DefaultPriority => 0; + + /// + /// 当前帧该运动控制组件是否希望接管节点运动。 + /// + public abstract bool CanControl(); + + /// + /// 当前运动控制组件开始接管节点时的回调。 + /// + public virtual void OnMotionActivated() + { + } + + /// + /// 当前运动控制组件失去节点控制权时的回调。 + /// + public virtual void OnMotionDeactivated() + { + } + + /// + /// 当前运动控制组件正在接管节点时,每个 FixedUpdate 执行的逻辑。 + /// + public abstract void TickMotion(float deltaTime); + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeMotionFeature.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeMotionFeature.cs.meta new file mode 100644 index 000000000..9c76ad302 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/Feature/FishingLineNodeMotionFeature.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 7e75217306a64f8f868f5f9127772de2 +timeCreated: 1775957711 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineNode.cs b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineNode.cs new file mode 100644 index 000000000..588e58dc4 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineNode.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace NBF +{ + public class FishingLineNode : MonoBehaviour + { + public enum NodeType + { + Start, + Float, + Weight, + Tail, + Virtual, + } + + private FishingLineSolver _solver; + + [Header("Node")] [SerializeField] private NodeType nodeType = NodeType.Tail; + [SerializeField] private Rigidbody body; + [SerializeField] private MonoBehaviour interaction; + + [Header("Segment To Next Logical Node")] [Min(0f)] [SerializeField] + private float segmentLengthToNext = 0.5f; + + [Min(0)] [SerializeField] private int fixedVirtualNodesToNext = 2; + + [Header("Runtime")] [SerializeField] private bool runtimeVirtualNode; + [SerializeField] private int runtimeChainIndex = -1; + + [Header("Debug")] [SerializeField] private bool drawDebugGizmo = true; + [Min(0.001f)] [SerializeField] private float debugGizmoRadius = 0.03f; + [SerializeField] private Color logicalNodeColor = new(0.2f, 0.9f, 0.2f, 1f); + [SerializeField] private Color virtualNodeColor = new(1f, 0.6f, 0.1f, 1f); + + + + [SerializeField] private List features = new(); + [SerializeField] private List motionFeatures = new(); + private bool featureCacheReady; + [SerializeField] private FishingLineNodeMotionFeature activeMotionFeature; + + /// + /// 当前正在接管节点运动的组件。 + /// + public FishingLineNodeMotionFeature ActiveMotionFeature => activeMotionFeature; + + public NodeType Type + { + get => nodeType; + set => nodeType = value; + } + + public Rigidbody Body => body; + + public MonoBehaviour Interaction => interaction; + + public float SegmentLengthToNext + { + get => segmentLengthToNext; + set => segmentLengthToNext = Mathf.Max(0f, value); + } + + public int FixedVirtualNodesToNext + { + get => fixedVirtualNodesToNext; + set => fixedVirtualNodesToNext = Mathf.Max(0, value); + } + + public bool IsRuntimeVirtualNode => runtimeVirtualNode; + + public int RuntimeChainIndex => runtimeChainIndex; + + public Vector3 Position => transform.position; + + public string GetDebugName() + { + return runtimeVirtualNode + ? $"V[{runtimeChainIndex}]" + : $"{nodeType}[{runtimeChainIndex}]"; + } + + public string GetDebugSummary() + { + var bodySummary = body == null + ? "NoBody" + : $"v:{body.linearVelocity.magnitude:F2}"; + + return $"{GetDebugName()} pos:{Position} next:{segmentLengthToNext:F2} {bodySummary}"; + } + + private void Reset() + { + TryGetComponent(out body); + } + + private void Awake() + { + _solver = GetComponentInParent(); + EnsureFeatureCache(); + } + + private void Start() + { + BindFeatures(_solver); + } + + private void FixedUpdate() + { + EnsureFeatureCache(); + UpdateMotionControl(Time.fixedDeltaTime); + } + + private void OnValidate() + { + if (body == null) + { + TryGetComponent(out body); + } + + segmentLengthToNext = Mathf.Max(0f, segmentLengthToNext); + fixedVirtualNodesToNext = Mathf.Max(0, fixedVirtualNodesToNext); + } + + public void SetRuntimeVirtual(bool isVirtual, int chainIndex) + { + runtimeVirtualNode = isVirtual; + runtimeChainIndex = chainIndex; + + if (isVirtual) + { + nodeType = NodeType.Virtual; + } + } + + public void SetVisualPosition(Vector3 position) + { + transform.position = position; + } + + #region Feature + + /// + /// 获取节点上的第一个指定类型功能组件。 + /// + public T GetFeature() where T : FishingLineNodeFeature + { + EnsureFeatureCache(); + for (var i = 0; i < features.Count; i++) + { + if (features[i] is T result) + { + return result; + } + } + + return null; + } + + /// + /// 尝试获取节点上的指定类型功能组件。 + /// + public bool TryGetFeature(out T feature) where T : FishingLineNodeFeature + { + feature = GetFeature(); + return feature != null; + } + + /// + /// 刷新并重新绑定当前节点上的功能组件。 + /// + public void BindFeatures(FishingLineSolver solver) + { + EnsureFeatureCache(); + foreach (var t in features) + { + t.Bind(this, solver); + } + + ResolveMotionFeature(forceRefresh: true); + } + + /// + /// 通知当前节点上的所有功能组件,鱼线已重建完成。 + /// + public void NotifyLineBuilt() + { + EnsureFeatureCache(); + foreach (var t in features) + { + t.OnLineBuilt(); + } + + ResolveMotionFeature(forceRefresh: true); + } + + /// + /// 通知当前节点上的所有功能组件,鱼线已经达到断线条件。 + /// + public void NotifyLineBreakRequested() + { + EnsureFeatureCache(); + foreach (var t in features) + { + t.OnLineBreakRequested(); + } + } + + + + private void EnsureFeatureCache() + { + if (!featureCacheReady) + { + RefreshFeatures(); + } + } + + private void RefreshFeatures() + { + features.Clear(); + motionFeatures.Clear(); + GetComponents(features); + for (var i = 0; i < features.Count; i++) + { + if (features[i] is FishingLineNodeMotionFeature motionFeature) + { + motionFeatures.Add(motionFeature); + } + } + + activeMotionFeature = null; + featureCacheReady = true; + } + + private void UpdateMotionControl(float deltaTime) + { + var motionFeature = ResolveMotionFeature(forceRefresh: false); + if (motionFeature == null) + { + return; + } + + motionFeature.TickMotion(deltaTime); + } + + private FishingLineNodeMotionFeature ResolveMotionFeature(bool forceRefresh) + { + EnsureFeatureCache(); + + var bestMotionFeature = default(FishingLineNodeMotionFeature); + var bestPriority = int.MinValue; + + foreach (var motionFeature in motionFeatures) + { + var r = !motionFeature.IsSupportedNode(this); + var n = !motionFeature.CanControl(); + if (motionFeature == null || !motionFeature.IsSupportedNode(this) || !motionFeature.CanControl()) + { + continue; + } + + if (bestMotionFeature != null && motionFeature.Priority <= bestPriority) + { + continue; + } + + bestMotionFeature = motionFeature; + bestPriority = motionFeature.Priority; + } + + if (!forceRefresh && ReferenceEquals(activeMotionFeature, bestMotionFeature)) + { + return activeMotionFeature; + } + + if (activeMotionFeature != null && !ReferenceEquals(activeMotionFeature, bestMotionFeature)) + { + activeMotionFeature.OnMotionDeactivated(); + } + + activeMotionFeature = bestMotionFeature; + if (activeMotionFeature != null) + { + activeMotionFeature.OnMotionActivated(); + } + + return activeMotionFeature; + } + + #endregion + + private void OnDrawGizmos() + { + if (!drawDebugGizmo) + { + return; + } + + Gizmos.color = runtimeVirtualNode ? virtualNodeColor : logicalNodeColor; + Gizmos.DrawSphere(transform.position, debugGizmoRadius); + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineNode.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineNode.cs.meta new file mode 100644 index 000000000..e10e4f09b --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 610df5569209e4b4997cb2dbf3b94cdc \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineRenderer.cs b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineRenderer.cs new file mode 100644 index 000000000..05f1872ad --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineRenderer.cs @@ -0,0 +1,717 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace NBF +{ + [RequireComponent(typeof(LineRenderer))] + public class FishingLineRenderer : MonoBehaviour + { + [Header("References")] + [SerializeField] private FishingLineSolver solver; + [SerializeField] private LineRenderer lineRenderer; + + [Header("Verlet")] + [Min(1)] + [SerializeField] private int solverIterations = 8; + [Range(0f, 1f)] + [SerializeField] private float damping = 0.98f; + [Min(0f)] + [SerializeField] private float gravityScale = 1f; + [Min(0.001f)] + [SerializeField] private float simulationStep = 0.0166667f; + [Min(0.001f)] + [SerializeField] private float maxDeltaTime = 0.0333333f; + + [Header("Water Surface")] + [SerializeField] private bool constrainToWaterSurface = true; + [SerializeField] private Transform waterSurfaceTransform; + [SerializeField] private float waterSurfaceHeight; + [Min(0)] + [SerializeField] private int ignoreHeadNodeCount = 1; + [Min(0)] + [SerializeField] private int ignoreTailNodeCount = 1; + [Min(0f)] + [SerializeField] private float waterSurfaceFollowSpeed = 12f; + + [Header("Stability")] + [Min(1)] + [SerializeField] private int maxSubStepsPerFrame = 2; + [Min(0f)] + [SerializeField] private float sleepVelocityThreshold = 0.001f; + [Min(0f)] + [SerializeField] private float sleepDistanceThreshold = 0.002f; + [Min(1)] + [SerializeField] private int stableFramesBeforeSleep = 4; + [Min(0f)] + [SerializeField] private float wakeDistanceThreshold = 0.001f; + [Min(0f)] + [SerializeField] private float tautSegmentThreshold = 0.002f; + [Min(0.001f)] + [SerializeField] private float tautTransitionRange = 0.03f; + + [Header("Corner Smoothing")] + [SerializeField] private bool smoothCorners = true; + [Range(0f, 180f)] + [SerializeField] private float minCornerAngle = 12f; + [Min(0f)] + [SerializeField] private float maxCornerSmoothDistance = 0.03f; + [Min(1)] + [SerializeField] private int cornerSmoothSubdivisions = 3; + + [Header("Debug")] + [SerializeField] private bool drawDebugSamples; + [SerializeField] private Color debugLogicalSampleColor = Color.cyan; + [SerializeField] private Color debugVirtualSampleColor = new(1f, 0.55f, 0.15f, 1f); + [Min(0.001f)] + [SerializeField] private float debugLogicalSampleRadius = 0.018f; + [Min(0.001f)] + [SerializeField] private float debugVirtualSampleRadius = 0.012f; + + private readonly List positions = new(); + private readonly List renderPositions = new(); + private readonly List previousPositions = new(); + private readonly List sampledPointKeys = new(); + private readonly List lastPinnedPointPositions = new(); + private readonly List lastRestLengths = new(); + private bool[] pinnedFlags = System.Array.Empty(); + private float accumulatedTime; + private bool isSleeping; + private int stableFrameCounter; + + public int SampleCount => positions.Count; + + public float CurrentRenderedLength + { + get + { + var total = 0f; + var source = renderPositions.Count > 0 ? renderPositions : positions; + for (var i = 0; i < source.Count - 1; i++) + { + total += Vector3.Distance(source[i], source[i + 1]); + } + + return total; + } + } + + private void Reset() + { + TryGetComponent(out lineRenderer); + if (solver == null) + { + TryGetComponent(out solver); + } + } + + private void Awake() + { + if (lineRenderer == null) + { + TryGetComponent(out lineRenderer); + } + } + + public void Render(FishingLineSolver sourceSolver, float deltaTime) + { + if (lineRenderer == null) + { + return; + } + + solver = sourceSolver; + var points = solver.ChainPoints; + var restLengths = solver.RestLengths; + var pinnedIndices = solver.PinnedIndices; + if (points.Count == 0) + { + lineRenderer.positionCount = 0; + return; + } + + var topologyChanged = EnsureBuffers(points, pinnedIndices); + if (topologyChanged || ShouldWake(points, restLengths)) + { + WakeUp(); + } + + Simulate(points, restLengths, deltaTime); + ApplyToRenderer(); + CacheFrameState(points, restLengths); + } + + private bool EnsureBuffers( + IReadOnlyList points, + IReadOnlyList pinnedIndices) + { + var topologyChanged = sampledPointKeys.Count != points.Count; + if (!topologyChanged) + { + for (var i = 0; i < points.Count; i++) + { + if (sampledPointKeys[i] == points[i].Key) + { + continue; + } + + topologyChanged = true; + break; + } + } + + var previousPositionMap = new Dictionary(sampledPointKeys.Count); + var previousHistoryMap = new Dictionary(sampledPointKeys.Count); + for (var i = 0; i < sampledPointKeys.Count; i++) + { + previousPositionMap[sampledPointKeys[i]] = positions[i]; + previousHistoryMap[sampledPointKeys[i]] = previousPositions[i]; + } + + positions.Clear(); + previousPositions.Clear(); + sampledPointKeys.Clear(); + pinnedFlags = new bool[points.Count]; + + for (var i = 0; i < points.Count; i++) + { + var point = points[i]; + sampledPointKeys.Add(point.Key); + + if (previousPositionMap.TryGetValue(point.Key, out var preservedPosition)) + { + positions.Add(preservedPosition); + previousPositions.Add(previousHistoryMap[point.Key]); + continue; + } + + positions.Add(point.Position); + previousPositions.Add(point.Position); + } + + for (var i = 0; i < pinnedIndices.Count; i++) + { + var pinnedIndex = pinnedIndices[i]; + if (pinnedIndex >= 0 && pinnedIndex < pinnedFlags.Length) + { + pinnedFlags[pinnedIndex] = true; + } + } + + return topologyChanged; + } + + private void Simulate( + IReadOnlyList points, + IReadOnlyList restLengths, + float deltaTime) + { + if (isSleeping) + { + PinLogicalPoints(points); + return; + } + + var clampedDelta = Mathf.Clamp(deltaTime, 0f, maxDeltaTime); + accumulatedTime = Mathf.Min(accumulatedTime + clampedDelta, simulationStep * maxSubStepsPerFrame); + + var subStepCount = 0; + while (accumulatedTime >= simulationStep && subStepCount < maxSubStepsPerFrame) + { + SimulateStep(points, restLengths, simulationStep); + accumulatedTime -= simulationStep; + subStepCount++; + } + + if (subStepCount == 0) + { + PinLogicalPoints(points); + ApplySleep(); + } + + EvaluateSleepState(restLengths); + } + + private void SimulateStep( + IReadOnlyList points, + IReadOnlyList restLengths, + float stepDelta) + { + var gravity = Physics.gravity * gravityScale * stepDelta * stepDelta; + + for (var i = 0; i < points.Count; i++) + { + if (pinnedFlags[i]) + { + positions[i] = points[i].Position; + previousPositions[i] = points[i].Position; + continue; + } + + var current = positions[i]; + var velocity = (current - previousPositions[i]) * damping; + previousPositions[i] = current; + positions[i] = current + velocity + gravity; + } + + SolveDistanceConstraints(points, restLengths); + + ApplyWaterSurfaceConstraint(stepDelta); + SolveDistanceConstraints(points, restLengths); + StraightenTautLogicalSegments(points, restLengths); + SolveDistanceConstraints(points, restLengths); + PinLogicalPoints(points); + ApplySleep(); + } + + private void SolveDistanceConstraints( + IReadOnlyList points, + IReadOnlyList restLengths) + { + for (var iteration = 0; iteration < solverIterations; iteration++) + { + PinLogicalPoints(points); + + for (var segmentIndex = 0; segmentIndex < restLengths.Count; segmentIndex++) + { + SatisfyDistanceConstraint(segmentIndex, restLengths[segmentIndex]); + } + } + } + + private void PinLogicalPoints(IReadOnlyList points) + { + for (var i = 0; i < points.Count; i++) + { + if (!pinnedFlags[i]) + { + continue; + } + + positions[i] = points[i].Position; + previousPositions[i] = points[i].Position; + } + } + + private void SatisfyDistanceConstraint(int segmentIndex, float restLength) + { + var pointA = positions[segmentIndex]; + var pointB = positions[segmentIndex + 1]; + var delta = pointB - pointA; + var distance = delta.magnitude; + if (distance <= 0.0001f) + { + return; + } + + var correctionScale = (distance - restLength) / distance; + if (Mathf.Approximately(correctionScale, 0f)) + { + return; + } + + var pointAPinned = pinnedFlags[segmentIndex]; + var pointBPinned = pinnedFlags[segmentIndex + 1]; + + if (pointAPinned && pointBPinned) + { + return; + } + + if (pointAPinned) + { + positions[segmentIndex + 1] -= delta * correctionScale; + return; + } + + if (pointBPinned) + { + positions[segmentIndex] += delta * correctionScale; + return; + } + + var correction = delta * (correctionScale * 0.5f); + positions[segmentIndex] += correction; + positions[segmentIndex + 1] -= correction; + } + + private void StraightenTautLogicalSegments( + IReadOnlyList points, + IReadOnlyList restLengths) + { + if (points.Count < 2 || restLengths.Count == 0) + { + return; + } + + var segmentStartIndex = 0; + while (segmentStartIndex < points.Count - 1) + { + if (!points[segmentStartIndex].IsLogical) + { + segmentStartIndex++; + continue; + } + + var segmentEndIndex = segmentStartIndex + 1; + while (segmentEndIndex < points.Count && !points[segmentEndIndex].IsLogical) + { + segmentEndIndex++; + } + + if (segmentEndIndex >= points.Count) + { + break; + } + + ProjectLogicalSegmentIfTaut(segmentStartIndex, segmentEndIndex, restLengths); + segmentStartIndex = segmentEndIndex; + } + } + + private void ProjectLogicalSegmentIfTaut( + int startIndex, + int endIndex, + IReadOnlyList restLengths) + { + if (endIndex - startIndex <= 1) + { + return; + } + + var segmentRestLength = 0f; + for (var i = startIndex; i < endIndex; i++) + { + segmentRestLength += restLengths[i]; + } + + var start = positions[startIndex]; + var end = positions[endIndex]; + var delta = end - start; + var endpointDistance = delta.magnitude; + if (endpointDistance <= 0.0001f) + { + return; + } + + var fullTautDistance = Mathf.Max(0f, segmentRestLength - tautSegmentThreshold); + var blendStartDistance = Mathf.Max(0f, fullTautDistance - tautTransitionRange); + if (endpointDistance < blendStartDistance) + { + return; + } + + var straightenBlend = blendStartDistance >= fullTautDistance + ? 1f + : Mathf.SmoothStep(0f, 1f, Mathf.InverseLerp(blendStartDistance, fullTautDistance, endpointDistance)); + + var direction = delta / endpointDistance; + var accumulatedDistance = 0f; + for (var pointIndex = startIndex + 1; pointIndex < endIndex; pointIndex++) + { + accumulatedDistance += restLengths[pointIndex - 1]; + var projectedPosition = start + direction * accumulatedDistance; + var currentPosition = positions[pointIndex]; + var blendedPosition = Vector3.Lerp(currentPosition, projectedPosition, straightenBlend); + positions[pointIndex] = blendedPosition; + previousPositions[pointIndex] = Vector3.Lerp(previousPositions[pointIndex], blendedPosition, straightenBlend); + } + } + + private void ApplyWaterSurfaceConstraint(float stepDelta) + { + if (!constrainToWaterSurface || positions.Count == 0) + { + return; + } + + var surfaceHeight = waterSurfaceTransform != null ? waterSurfaceTransform.position.y : waterSurfaceHeight; + var startIndex = Mathf.Clamp(ignoreHeadNodeCount, 0, positions.Count); + var endExclusive = Mathf.Clamp(positions.Count - ignoreTailNodeCount, startIndex, positions.Count); + var followFactor = Mathf.Clamp01(waterSurfaceFollowSpeed * stepDelta); + + for (var i = startIndex; i < endExclusive; i++) + { + if (pinnedFlags[i]) + { + continue; + } + + var current = positions[i]; + if (current.y >= surfaceHeight) + { + continue; + } + + var nextY = Mathf.Lerp(current.y, surfaceHeight, followFactor); + positions[i] = new Vector3(current.x, nextY, current.z); + + var previous = previousPositions[i]; + previousPositions[i] = new Vector3( + previous.x, + Mathf.Lerp(previous.y, nextY, followFactor), + previous.z); + } + } + + private void ApplySleep() + { + for (var i = 0; i < positions.Count; i++) + { + if (pinnedFlags[i]) + { + continue; + } + + var velocityMagnitude = (positions[i] - previousPositions[i]).magnitude; + if (velocityMagnitude <= sleepVelocityThreshold) + { + previousPositions[i] = positions[i]; + } + } + } + + private void EvaluateSleepState(IReadOnlyList restLengths) + { + var isStable = true; + for (var i = 0; i < positions.Count; i++) + { + if (pinnedFlags[i]) + { + continue; + } + + if ((positions[i] - previousPositions[i]).magnitude > sleepVelocityThreshold) + { + isStable = false; + break; + } + } + + if (isStable) + { + for (var i = 0; i < restLengths.Count; i++) + { + var error = Mathf.Abs(Vector3.Distance(positions[i], positions[i + 1]) - restLengths[i]); + if (error > sleepDistanceThreshold) + { + isStable = false; + break; + } + } + } + + if (!isStable) + { + stableFrameCounter = 0; + return; + } + + stableFrameCounter++; + if (stableFrameCounter < stableFramesBeforeSleep) + { + return; + } + + isSleeping = true; + accumulatedTime = 0f; + for (var i = 0; i < positions.Count; i++) + { + previousPositions[i] = positions[i]; + } + } + + private bool ShouldWake( + IReadOnlyList points, + IReadOnlyList restLengths) + { + if (!isSleeping) + { + return false; + } + + if (lastPinnedPointPositions.Count != points.Count || lastRestLengths.Count != restLengths.Count) + { + return true; + } + + for (var i = 0; i < points.Count; i++) + { + if (!pinnedFlags[i]) + { + continue; + } + + if (Vector3.Distance(points[i].Position, lastPinnedPointPositions[i]) > wakeDistanceThreshold) + { + return true; + } + } + + for (var i = 0; i < restLengths.Count; i++) + { + if (Mathf.Abs(restLengths[i] - lastRestLengths[i]) > wakeDistanceThreshold) + { + return true; + } + } + + return false; + } + + private void CacheFrameState( + IReadOnlyList points, + IReadOnlyList restLengths) + { + lastPinnedPointPositions.Clear(); + for (var i = 0; i < points.Count; i++) + { + lastPinnedPointPositions.Add(points[i].Position); + } + + lastRestLengths.Clear(); + for (var i = 0; i < restLengths.Count; i++) + { + lastRestLengths.Add(restLengths[i]); + } + } + + private void WakeUp() + { + isSleeping = false; + stableFrameCounter = 0; + accumulatedTime = 0f; + } + + private void ApplyToRenderer() + { + BuildRenderPositions(); + + lineRenderer.positionCount = renderPositions.Count; + for (var i = 0; i < renderPositions.Count; i++) + { + lineRenderer.SetPosition(i, renderPositions[i]); + } + } + + private void BuildRenderPositions() + { + renderPositions.Clear(); + if (positions.Count == 0) + { + return; + } + + if (!smoothCorners || positions.Count < 3) + { + renderPositions.AddRange(positions); + return; + } + + renderPositions.Add(positions[0]); + + for (var i = 1; i < positions.Count - 1; i++) + { + var previous = positions[i - 1]; + var current = positions[i]; + var next = positions[i + 1]; + + if (!TryBuildSmoothedCorner(previous, current, next, out var entry, out var exit)) + { + AddRenderPointIfDistinct(current); + continue; + } + + AddRenderPointIfDistinct(entry); + for (var subdivision = 1; subdivision <= cornerSmoothSubdivisions; subdivision++) + { + var t = subdivision / (cornerSmoothSubdivisions + 1f); + var pointOnCurve = EvaluateQuadraticBezier(entry, current, exit, t); + AddRenderPointIfDistinct(pointOnCurve); + } + + AddRenderPointIfDistinct(exit); + } + + AddRenderPointIfDistinct(positions[^1]); + } + + private bool TryBuildSmoothedCorner( + Vector3 previous, + Vector3 current, + Vector3 next, + out Vector3 entry, + out Vector3 exit) + { + entry = current; + exit = current; + + var incoming = current - previous; + var outgoing = next - current; + var incomingLength = incoming.magnitude; + var outgoingLength = outgoing.magnitude; + if (incomingLength <= 0.0001f || outgoingLength <= 0.0001f) + { + return false; + } + + var cornerAngle = Vector3.Angle(incoming, outgoing); + if (cornerAngle < minCornerAngle) + { + return false; + } + + var trimDistance = Mathf.Min( + maxCornerSmoothDistance, + incomingLength * 0.5f, + outgoingLength * 0.5f); + + if (trimDistance <= 0.0001f) + { + return false; + } + + entry = current - (incoming / incomingLength) * trimDistance; + exit = current + (outgoing / outgoingLength) * trimDistance; + return true; + } + + private void AddRenderPointIfDistinct(Vector3 point) + { + if (renderPositions.Count > 0 && Vector3.Distance(renderPositions[^1], point) <= 0.0001f) + { + return; + } + + renderPositions.Add(point); + } + + private static Vector3 EvaluateQuadraticBezier(Vector3 start, Vector3 control, Vector3 end, float t) + { + var oneMinusT = 1f - t; + return (oneMinusT * oneMinusT * start) + + (2f * oneMinusT * t * control) + + (t * t * end); + } + + private void OnDrawGizmosSelected() + { + if (!drawDebugSamples || positions.Count == 0) + { + return; + } + + for (var i = 0; i < positions.Count; i++) + { + var isLogicalPoint = solver != null + && solver.ChainPoints != null + && i < solver.ChainPoints.Count + && solver.ChainPoints[i].IsLogical; + + Gizmos.color = isLogicalPoint ? debugLogicalSampleColor : debugVirtualSampleColor; + Gizmos.DrawSphere( + positions[i], + isLogicalPoint ? debugLogicalSampleRadius : debugVirtualSampleRadius); + } + } + } +} diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineRenderer.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineRenderer.cs.meta new file mode 100644 index 000000000..f5104d5bc --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineRenderer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 827786ffede4e7b4781c522e8a4ba9d0 \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineSolver.cs b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineSolver.cs new file mode 100644 index 000000000..ccaddb955 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineSolver.cs @@ -0,0 +1,926 @@ +using System; +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace NBF +{ + public class FishingLineSolver : MonoBehaviour + { + [Serializable] + public sealed class ChainPoint + { + public long Key; + public Vector3 Position; + public bool IsLogical; + public FishingLineNode LogicalNode; + public int SegmentIndex; + public int StableIndex; + + public string GetDebugName() + { + if (IsLogical) + { + return LogicalNode != null ? LogicalNode.GetDebugName() : $"L[{StableIndex}]"; + } + + return $"V[S{SegmentIndex}:{StableIndex}]"; + } + } + + [Serializable] + private struct SegmentLayout + { + public float[] GapLengths; + + public int VirtualNodeCount => Mathf.Max(0, GapLengths.Length - 1); + } + + [Header("References")] [SerializeField] + private Transform anchorTransform; + + [SerializeField] private FishingLineNode[] logicalNodes = Array.Empty(); + [SerializeField] private FishingLineRenderer lineRenderer; + + [Header("First Segment")] [Min(0f)] [SerializeField] + private float firstSegmentLength = 1.2f; + + [Min(0.001f)] [SerializeField] private float firstSegmentStep = 0.1f; + + [Header("Joint")] [Min(1)] [SerializeField] + private int jointSolverIterations = 12; + + [SerializeField] private float jointProjectionDistance = 0.02f; + [SerializeField] private float jointProjectionAngle = 1f; + + [Header("Limit Detection")] + [Min(0f)] + // 极限判定的长度容差,允许链路在总长或单段长度上存在少量误差。 + [SerializeField] + private float lengthLimitTolerance = 0.01f; + + [Min(0f)] + // 达到极限后,只有当前超长值大于该阈值时,才开始进入断线候选计时。 + [SerializeField] + private float breakStretchThreshold = 0.05f; + + [Min(0f)] + // 断线候选状态允许持续的最大时间;超过后会发出一次断线消息。 + [SerializeField] + private float breakLimitDuration = 0.15f; + + [Header("Runtime Debug")] [SerializeField] + private bool autoBuildOnStart = true; + + private readonly List chainPoints = new(); + private readonly List restLengths = new(); + private readonly List pinnedIndices = new(); + private readonly List runtimeJoints = new(); + + private bool chainDirty = true; + private int runtimeVirtualPointCount; + + public float FirstSegmentLength => firstSegmentLength; + + public float FirstSegmentStep => firstSegmentStep; + + public IReadOnlyList ChainPoints => chainPoints; + + public IReadOnlyList RestLengths => restLengths; + + public IReadOnlyList PinnedIndices => pinnedIndices; + + /// + /// 当前配置的逻辑节点只读列表。 + /// 外部可读取节点顺序,但不应直接修改数组内容。 + /// + public IReadOnlyList LogicalNodes => logicalNodes; + + /// + /// 当前整条鱼线的配置总长度,等于所有段的静止长度之和。 + /// + public float TotalLineLength { get; private set; } + + /// + /// 当前逻辑节点链路的实际总长度,按相邻逻辑节点的实际距离累加。 + /// + public float CurrentLogicalChainLength { get; private set; } + + /// + /// 当前首尾逻辑节点之间的直线距离,仅作为端点跨度观察值。 + /// + public float CurrentEndpointDistance { get; private set; } + + /// + /// 当前逻辑链总长度超出配置总长度的部分,小于等于零时记为 0。 + /// + public float CurrentStretchLength { get; private set; } + + /// + /// 当前所有逻辑段中,单段超出配置长度的最大值。 + /// + public float MaxSegmentStretchLength { get; private set; } + + /// + /// 当前超限最明显的逻辑段索引;为 -1 表示没有段处于超限。 + /// + public int MaxOverstretchedSegmentIndex { get; private set; } = -1; + + /// + /// 当前受拉比例。 + /// 该值取“单段实际长度 / 单段配置长度”和“整链实际长度 / 整链配置长度”中的最大值。 + /// 约等于 1 表示接近拉直,大于 1 表示已经出现超限拉伸。 + /// + public float CurrentTensionRatio { get; private set; } + + /// + /// 断线候选拉伸阈值。 + /// 只有当前处于极限状态,且超长值大于该阈值时,才会开始累计断线计时。 + /// + public float BreakStretchThreshold => breakStretchThreshold; + + /// + /// 断线候选状态可持续的最大时间。 + /// 当断线计时超过该值时,会发出一次断线消息。 + /// + public float BreakLimitDuration => breakLimitDuration; + + /// + /// 当前拉力极限百分比。 + /// 当超长值小于等于 lengthLimitTolerance 时为 0; + /// 当超长值大于等于 breakStretchThreshold 时为 100; + /// 中间区间按线性比例映射,供 UI 显示使用。 + /// + public float CurrentBreakStretchPercent => EvaluateBreakStretchPercent(CurrentStretchLength); + + /// + /// 当前是否处于极限状态。 + /// 只要整链超出总长度容差,或任一逻辑段超出单段容差,即认为到达极限。 + /// + public bool IsAtLimit { get; private set; } + + /// + /// 当前断线候选状态的累计时间。 + /// 只有在处于极限状态,且 CurrentStretchLength 大于断线阈值时才会累加;否则重置为 0。 + /// + public float LimitStateTime { get; private set; } + + /// + /// 当前是否正在进行断线候选计时。 + /// + public bool IsBreakCountdownActive => IsAtLimit && CurrentStretchLength > breakStretchThreshold; + + /// + /// 当前极限断线消息是否已经发出过。 + /// 在退出断线候选状态前只会发一次,避免重复通知。 + /// + public bool HasBreakNotificationSent { get; private set; } + + /// + /// 当鱼线达到断线条件时发出的一次性消息。 + /// 外部可订阅该事件,在回调中执行切线、播放表现或状态切换。 + /// + public event Action OnLineBreakRequested; + + public int LogicalNodeCount => logicalNodes?.Length ?? 0; + + public int RuntimeVirtualNodeCount => runtimeVirtualPointCount; + + public int ActiveRuntimeVirtualNodeCount => runtimeVirtualPointCount; + + public int OrderedNodeCount => chainPoints.Count; + + public int SegmentCount => restLengths.Count; + + public bool IsChainDirty => chainDirty; + + private void Reset() + { + if (lineRenderer == null) + { + TryGetComponent(out lineRenderer); + } + } + + private void Start() + { + if (autoBuildOnStart) + { + BuildLine(); + } + } + + private void FixedUpdate() + { + if (logicalNodes == null || logicalNodes.Length == 0) + { + ResetLimitState(); + return; + } + + UpdateAnchorNode(); + + if (chainDirty) + { + RebuildRuntimeChain(); + } + + EvaluateLimitState(Time.fixedDeltaTime); + } + + private void LateUpdate() + { + if (chainDirty) + { + RebuildRuntimeChain(); + } + + SyncLogicalPointPositions(); + + if (lineRenderer != null && chainPoints.Count > 1) + { + lineRenderer.Render(this, Time.deltaTime); + } + } + + private void OnValidate() + { + firstSegmentLength = Mathf.Max(0f, firstSegmentLength); + firstSegmentStep = Mathf.Max(0.001f, firstSegmentStep); + jointSolverIterations = Mathf.Max(1, jointSolverIterations); + lengthLimitTolerance = Mathf.Max(0f, lengthLimitTolerance); + breakStretchThreshold = Mathf.Max(0f, breakStretchThreshold); + breakLimitDuration = Mathf.Max(0f, breakLimitDuration); + chainDirty = true; + } + + /// + /// 按当前配置重建整条鱼线的运行时链路,并立即刷新极限状态。 + /// + [ContextMenu("Build Line")] + public void BuildLine() + { + ConfigureStartNode(); + ConfigureLogicalJoints(); + RebuildRuntimeChain(); + EvaluateLimitState(0f); + } + + /// + /// 设置指定逻辑段的配置长度。 + /// segmentIndex 为 0 时表示第一段;大于 0 时表示对应逻辑节点到下一个逻辑节点的线长。 + /// + public void SetLenght(float length, int segmentIndex = 0) + { + var clamped = Mathf.Max(0f, length); + var currentLength = GetSegmentLength(segmentIndex); + if (Mathf.Approximately(clamped, currentLength)) + { + return; + } + + if (segmentIndex <= 0) + { + firstSegmentLength = clamped; + } + else + { + if (logicalNodes == null || segmentIndex >= logicalNodes.Length) + { + return; + } + + var sourceNode = logicalNodes[segmentIndex]; + if (sourceNode == null) + { + return; + } + + sourceNode.SegmentLengthToNext = clamped; + } + + UpdateJointLimit(segmentIndex + 1, clamped); + chainDirty = true; + } + + /// + /// 立即按当前配置重建鱼线。 + /// + public void RebuildNow() + { + BuildLine(); + } + + /// + /// 获取指定顺序索引的逻辑节点。 + /// 索引基于 logicalNodes 配置顺序;超出范围或节点为空时返回 null。 + /// + public FishingLineNode GetLogicalNode(int logicalIndex) + { + if (logicalNodes == null || logicalIndex < 0 || logicalIndex >= logicalNodes.Length) + { + return null; + } + + return logicalNodes[logicalIndex]; + } + + /// + /// 尝试获取指定顺序索引的逻辑节点。 + /// 获取失败时返回 false,并将 node 置为 null。 + /// + public bool TryGetLogicalNode(int logicalIndex, out FishingLineNode node) + { + node = GetLogicalNode(logicalIndex); + return node != null; + } + + /// + /// 获取当前起点逻辑节点。 + /// 会返回配置顺序中第一个非空节点。 + /// + public FishingLineNode GetStartNode() + { + return FindFirstValidLogicalNode(); + } + + /// + /// 获取当前终点逻辑节点。 + /// 会返回配置顺序中最后一个非空节点。 + /// + public FishingLineNode GetEndNode() + { + return FindLastValidLogicalNode(); + } + + /// + /// 获取当前运行时链路中相邻采样点的实际距离。 + /// 这里的采样点包含逻辑节点和虚拟节点。 + /// + public float GetActualDistance(int segmentIndex) + { + if (segmentIndex < 0 || segmentIndex >= chainPoints.Count - 1) + { + return 0f; + } + + return Vector3.Distance(chainPoints[segmentIndex].Position, chainPoints[segmentIndex + 1].Position); + } + + /// + /// 返回当前鱼线运行时调试摘要,包含链路结构、长度和极限状态信息。 + /// + public string GetRuntimeDebugSummary() + { + var builder = new StringBuilder(512); + builder.Append("chain:") + .Append(chainDirty ? "dirty" : "ready") + .Append(" logical:") + .Append(LogicalNodeCount) + .Append(" runtimeVirtual:") + .Append(RuntimeVirtualNodeCount) + .Append(" ordered:") + .Append(OrderedNodeCount) + .Append(" total:") + .Append(TotalLineLength.ToString("F2")) + .Append("m") + .Append(" chain:") + .Append(CurrentLogicalChainLength.ToString("F2")) + .Append("m") + .Append(" tension:") + .Append(CurrentTensionRatio.ToString("F3")) + .Append(" breakPercent:") + .Append(CurrentBreakStretchPercent.ToString("F1")) + .Append('%') + .Append(" limit:") + .Append(IsAtLimit ? "yes" : "no") + .Append(" limitTime:") + .Append(LimitStateTime.ToString("F2")) + .Append("s"); + + for (var i = 0; i < chainPoints.Count; i++) + { + var point = chainPoints[i]; + builder.AppendLine() + .Append('#') + .Append(i) + .Append(' ') + .Append(point.GetDebugName()) + .Append(" pos:") + .Append(point.Position); + + if (point.IsLogical && point.LogicalNode != null) + { + builder.Append(" body:") + .Append(point.LogicalNode.Body != null ? "yes" : "no"); + } + + if (i < restLengths.Count) + { + builder.Append(" seg rest:") + .Append(restLengths[i].ToString("F3")) + .Append(" actual:") + .Append(GetActualDistance(i).ToString("F3")); + } + } + + return builder.ToString(); + } + + private void ConfigureStartNode() + { + if (logicalNodes == null || logicalNodes.Length == 0 || logicalNodes[0] == null) + { + return; + } + + var startNode = logicalNodes[0]; + startNode.Type = FishingLineNode.NodeType.Start; + + if (startNode.Body != null) + { + startNode.Body.isKinematic = true; + startNode.Body.interpolation = RigidbodyInterpolation.Interpolate; + startNode.Body.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; + } + + UpdateAnchorNode(); + } + + private void ConfigureLogicalJoints() + { + runtimeJoints.Clear(); + + if (logicalNodes == null) + { + return; + } + + for (var i = 1; i < logicalNodes.Length; i++) + { + var current = logicalNodes[i]; + var previous = logicalNodes[i - 1]; + if (current == null || previous == null || current.Body == null || previous.Body == null) + { + continue; + } + + current.Body.solverIterations = jointSolverIterations; + current.Body.interpolation = RigidbodyInterpolation.Interpolate; + current.Body.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic; + + var joint = current.GetComponent(); + if (joint == null) + { + joint = current.gameObject.AddComponent(); + } + + joint.autoConfigureConnectedAnchor = true; + joint.connectedBody = previous.Body; + joint.xMotion = ConfigurableJointMotion.Limited; + joint.yMotion = ConfigurableJointMotion.Limited; + joint.zMotion = ConfigurableJointMotion.Limited; + joint.angularXMotion = ConfigurableJointMotion.Free; + joint.angularYMotion = ConfigurableJointMotion.Free; + joint.angularZMotion = ConfigurableJointMotion.Free; + joint.projectionMode = JointProjectionMode.PositionAndRotation; + joint.projectionDistance = jointProjectionDistance; + joint.projectionAngle = jointProjectionAngle; + + var limit = joint.linearLimit; + limit.limit = GetSegmentLength(i - 1); + joint.linearLimit = limit; + + runtimeJoints.Add(joint); + } + } + + private void UpdateAnchorNode() + { + if (anchorTransform == null || logicalNodes == null || logicalNodes.Length == 0 || logicalNodes[0] == null) + { + return; + } + + var startNode = logicalNodes[0]; + startNode.transform.SetPositionAndRotation(anchorTransform.position, anchorTransform.rotation); + + if (startNode.Body != null) + { + if (!startNode.Body.isKinematic) + { + startNode.Body.linearVelocity = Vector3.zero; + startNode.Body.angularVelocity = Vector3.zero; + } + } + } + + private void EvaluateLimitState(float deltaTime) + { + CurrentLogicalChainLength = 0f; + CurrentEndpointDistance = 0f; + CurrentStretchLength = 0f; + MaxSegmentStretchLength = 0f; + MaxOverstretchedSegmentIndex = -1; + CurrentTensionRatio = 0f; + + if (logicalNodes == null || logicalNodes.Length < 2) + { + SetLimitState(false); + UpdateBreakCountdown(deltaTime); + return; + } + + FishingLineNode firstNode = null; + FishingLineNode lastNode = null; + + for (var segmentIndex = 0; segmentIndex < logicalNodes.Length; segmentIndex++) + { + var node = logicalNodes[segmentIndex]; + if (node == null) + { + continue; + } + + firstNode ??= node; + lastNode = node; + + if (segmentIndex >= logicalNodes.Length - 1) + { + continue; + } + + var nextNode = logicalNodes[segmentIndex + 1]; + if (nextNode == null) + { + continue; + } + + var actualDistance = Vector3.Distance(node.Position, nextNode.Position); + var configuredLength = GetSegmentLength(segmentIndex); + CurrentLogicalChainLength += actualDistance; + + if (configuredLength > 0.0001f) + { + CurrentTensionRatio = Mathf.Max(CurrentTensionRatio, actualDistance / configuredLength); + } + + var segmentStretchLength = Mathf.Max(0f, actualDistance - configuredLength); + if (segmentStretchLength > MaxSegmentStretchLength) + { + MaxSegmentStretchLength = segmentStretchLength; + MaxOverstretchedSegmentIndex = segmentStretchLength > 0f ? segmentIndex : -1; + } + } + + if (firstNode != null && lastNode != null && !ReferenceEquals(firstNode, lastNode)) + { + CurrentEndpointDistance = Vector3.Distance(firstNode.Position, lastNode.Position); + } + + if (TotalLineLength > 0.0001f) + { + CurrentStretchLength = Mathf.Max(0f, CurrentLogicalChainLength - TotalLineLength); + CurrentTensionRatio = Mathf.Max(CurrentTensionRatio, CurrentLogicalChainLength / TotalLineLength); + } + else if (CurrentLogicalChainLength > 0f) + { + CurrentStretchLength = CurrentLogicalChainLength; + CurrentTensionRatio = Mathf.Max(CurrentTensionRatio, 1f); + } + + var exceedsTotalLength = CurrentStretchLength > lengthLimitTolerance; + var exceedsSegmentLength = MaxSegmentStretchLength > lengthLimitTolerance; + SetLimitState(exceedsTotalLength || exceedsSegmentLength); + UpdateBreakCountdown(deltaTime); + } + + private void SetLimitState(bool isAtLimit) + { + IsAtLimit = isAtLimit; + } + + private void UpdateBreakCountdown(float deltaTime) + { + if (!IsBreakCountdownActive) + { + LimitStateTime = 0f; + HasBreakNotificationSent = false; + return; + } + + LimitStateTime += Mathf.Max(0f, deltaTime); + + if (HasBreakNotificationSent || LimitStateTime < breakLimitDuration) + { + return; + } + + HasBreakNotificationSent = true; + NotifyLineBreakRequested(); + } + + /// + /// 发出鱼线达到断线条件的消息。 + /// 这里预留给外部订阅,当前不在求解器内部直接执行断线逻辑。 + /// + private void NotifyLineBreakRequested() + { + OnLineBreakRequested?.Invoke(this); + } + + private void ResetLimitState() + { + CurrentLogicalChainLength = 0f; + CurrentEndpointDistance = 0f; + CurrentStretchLength = 0f; + MaxSegmentStretchLength = 0f; + MaxOverstretchedSegmentIndex = -1; + CurrentTensionRatio = 0f; + IsAtLimit = false; + LimitStateTime = 0f; + HasBreakNotificationSent = false; + } + + private float EvaluateBreakStretchPercent(float stretchLength) + { + if (stretchLength <= lengthLimitTolerance) + { + return 0f; + } + + if (stretchLength >= breakStretchThreshold) + { + return 100f; + } + + if (breakStretchThreshold <= lengthLimitTolerance) + { + return 100f; + } + + return Mathf.InverseLerp(lengthLimitTolerance, breakStretchThreshold, stretchLength) * 100f; + } + + private void RebuildRuntimeChain() + { + chainPoints.Clear(); + restLengths.Clear(); + pinnedIndices.Clear(); + TotalLineLength = 0f; + runtimeVirtualPointCount = 0; + + if (logicalNodes == null || logicalNodes.Length < 2) + { + chainDirty = false; + return; + } + + var segmentLayouts = new SegmentLayout[logicalNodes.Length - 1]; + for (var segmentIndex = 0; segmentIndex < segmentLayouts.Length; segmentIndex++) + { + segmentLayouts[segmentIndex] = BuildSegmentLayout(segmentIndex); + } + + AddLogicalPoint(logicalNodes[0], 0); + pinnedIndices.Add(0); + + for (var segmentIndex = 0; segmentIndex < segmentLayouts.Length; segmentIndex++) + { + var fromNode = logicalNodes[segmentIndex]; + var toNode = logicalNodes[segmentIndex + 1]; + if (fromNode == null || toNode == null) + { + continue; + } + + var layout = segmentLayouts[segmentIndex]; + AddVirtualPoints(fromNode.Position, toNode.Position, layout, segmentIndex); + + for (var gapIndex = 0; gapIndex < layout.GapLengths.Length; gapIndex++) + { + restLengths.Add(layout.GapLengths[gapIndex]); + TotalLineLength += layout.GapLengths[gapIndex]; + } + + AddLogicalPoint(toNode, segmentIndex + 1); + pinnedIndices.Add(chainPoints.Count - 1); + } + + chainDirty = false; + } + + private SegmentLayout BuildSegmentLayout(int segmentIndex) + { + var totalLength = GetSegmentLength(segmentIndex); + if (segmentIndex == 0) + { + return new SegmentLayout + { + GapLengths = BuildFirstSegmentGaps(totalLength, firstSegmentStep), + }; + } + + var sourceNode = logicalNodes[segmentIndex]; + var virtualCount = sourceNode != null ? sourceNode.FixedVirtualNodesToNext : 0; + var gapCount = Mathf.Max(1, virtualCount + 1); + var gapLength = totalLength / gapCount; + var gaps = new float[gapCount]; + for (var i = 0; i < gaps.Length; i++) + { + gaps[i] = gapLength; + } + + return new SegmentLayout + { + GapLengths = gaps, + }; + } + + private void AddLogicalPoint(FishingLineNode logicalNode, int logicalIndex) + { + if (logicalNode == null) + { + return; + } + + chainPoints.Add(new ChainPoint + { + Key = BuildLogicalPointKey(logicalIndex), + Position = logicalNode.Position, + IsLogical = true, + LogicalNode = logicalNode, + SegmentIndex = logicalIndex, + StableIndex = logicalIndex, + }); + } + + private void AddVirtualPoints(Vector3 fromPosition, Vector3 toPosition, SegmentLayout layout, int segmentIndex) + { + if (layout.VirtualNodeCount == 0) + { + return; + } + + var direction = toPosition - fromPosition; + var distance = direction.magnitude; + var normalizedDirection = distance > 0.0001f ? direction / distance : Vector3.down; + var accumulatedDistance = 0f; + + for (var virtualIndex = 0; virtualIndex < layout.VirtualNodeCount; virtualIndex++) + { + accumulatedDistance += layout.GapLengths[virtualIndex]; + var stableIndex = segmentIndex == 0 + ? layout.VirtualNodeCount - 1 - virtualIndex + : virtualIndex; + + chainPoints.Add(new ChainPoint + { + Key = BuildVirtualPointKey(segmentIndex, stableIndex), + Position = fromPosition + normalizedDirection * accumulatedDistance, + IsLogical = false, + LogicalNode = null, + SegmentIndex = segmentIndex, + StableIndex = stableIndex, + }); + runtimeVirtualPointCount++; + } + } + + private void SyncLogicalPointPositions() + { + for (var i = 0; i < chainPoints.Count; i++) + { + var point = chainPoints[i]; + if (!point.IsLogical || point.LogicalNode == null) + { + continue; + } + + point.Position = point.LogicalNode.Position; + } + } + + private static float[] BuildFirstSegmentGaps(float totalLength, float step) + { + if (totalLength <= 0f) + { + return new[] { 0f }; + } + + if (totalLength < step) + { + return new[] { totalLength }; + } + + var fullStepCount = Mathf.FloorToInt(totalLength / step); + var remainder = totalLength - (fullStepCount * step); + if (remainder > 0.0001f) + { + var gaps = new float[fullStepCount + 1]; + gaps[0] = remainder; + for (var i = 1; i < gaps.Length; i++) + { + gaps[i] = step; + } + + return gaps; + } + + var divisibleGaps = new float[fullStepCount]; + for (var i = 0; i < divisibleGaps.Length; i++) + { + divisibleGaps[i] = step; + } + + return divisibleGaps; + } + + + private void UpdateJointLimit(int logicalNodeIndex, float limitValue) + { + if (logicalNodeIndex <= 0 || logicalNodeIndex >= logicalNodes.Length) + { + return; + } + + var node = logicalNodes[logicalNodeIndex]; + if (node == null) + { + return; + } + + var joint = node.GetComponent(); + if (joint == null) + { + return; + } + + var limit = joint.linearLimit; + limit.limit = limitValue; + joint.linearLimit = limit; + } + + private float GetSegmentLength(int segmentIndex) + { + if (segmentIndex <= 0) + { + return firstSegmentLength; + } + + if (logicalNodes == null || segmentIndex >= logicalNodes.Length) + { + return 0f; + } + + var sourceNode = logicalNodes[segmentIndex]; + return sourceNode != null ? sourceNode.SegmentLengthToNext : 0f; + } + + private FishingLineNode FindFirstValidLogicalNode() + { + if (logicalNodes == null) + { + return null; + } + + for (var i = 0; i < logicalNodes.Length; i++) + { + if (logicalNodes[i] != null) + { + return logicalNodes[i]; + } + } + + return null; + } + + private FishingLineNode FindLastValidLogicalNode() + { + if (logicalNodes == null) + { + return null; + } + + for (var i = logicalNodes.Length - 1; i >= 0; i--) + { + if (logicalNodes[i] != null) + { + return logicalNodes[i]; + } + } + + return null; + } + + private static long BuildLogicalPointKey(int logicalIndex) + { + return (1L << 62) | (uint)logicalIndex; + } + + private static long BuildVirtualPointKey(int segmentIndex, int stableIndex) + { + return ((long)(segmentIndex + 1) << 32) | (uint)stableIndex; + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineSolver.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineSolver.cs.meta new file mode 100644 index 000000000..f7d912b30 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineSolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dcd0fd8d96f994444b2d8663af6b915d \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineTestController.cs b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineTestController.cs new file mode 100644 index 000000000..d801e3778 --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineTestController.cs @@ -0,0 +1,103 @@ +using UnityEngine; + +namespace NBF +{ + public class FishingLineTestController : MonoBehaviour + { + [Header("References")] [SerializeField] + private FishingLineSolver solver; + + [Header("Length Test")] [Min(0f)] [SerializeField] + private float initialFirstSegmentLength = 1.2f; + + [Min(0f)] [SerializeField] private float minFirstSegmentLength = 0.1f; + [Min(0f)] [SerializeField] private float maxFirstSegmentLength = 5f; + [Min(0f)] [SerializeField] private float lineAdjustSpeed = 1f; + + [Header("Input")] [SerializeField] private KeyCode extendKey = KeyCode.UpArrow; + [SerializeField] private KeyCode retractKey = KeyCode.DownArrow; + + private float targetFirstSegmentLength; + + private void Reset() + { + if (solver == null) + { + solver = GetComponent(); + } + } + + private void Start() + { + if (solver == null) + { + return; + } + + targetFirstSegmentLength = + Mathf.Clamp(initialFirstSegmentLength, minFirstSegmentLength, maxFirstSegmentLength); + solver.SetLenght(targetFirstSegmentLength); + solver.BuildLine(); + + solver.OnLineBreakRequested += OnLineBreakRequested; + } + + private void OnLineBreakRequested(FishingLineSolver lineSolver) + { + Debug.LogError( + $"当前拉力达到极限,切线,极限时间={lineSolver.LimitStateTime} CurrentStretchLength={lineSolver.CurrentStretchLength} CurrentTensionRatio={lineSolver.CurrentTensionRatio}"); + var endNode = lineSolver.GetEndNode(); + if (endNode != null) + { + endNode.Body.isKinematic = false; + } + } + + private void Update() + { + if (solver == null) + { + return; + } + + var input = 0f; + if (Input.GetKey(extendKey)) + { + input += 1f; + } + + if (Input.GetKey(retractKey)) + { + input -= 1f; + } + + if (!Mathf.Approximately(input, 0f)) + { + targetFirstSegmentLength += input * lineAdjustSpeed * Time.deltaTime; + targetFirstSegmentLength = + Mathf.Clamp(targetFirstSegmentLength, minFirstSegmentLength, maxFirstSegmentLength); + solver.SetLenght(targetFirstSegmentLength); + } + + if (solver.CurrentBreakStretchPercent > 0) + { + // Debug.LogError(solver.CurrentBreakStretchPercent); + } + // if (solver.IsAtLimit) + // { + // if (solver.CurrentStretchLength > 0.04) + // Debug.LogError($"CurrentStretchLength={solver.CurrentStretchLength}"); + // if (solver.CurrentStretchLength > 0.1 && solver.LimitStateTime > 2f) + // { + // Debug.LogError( + // $"当前拉力达到极限,切线,极限时间={solver.LimitStateTime} CurrentStretchLength={solver.CurrentStretchLength} CurrentTensionRatio={solver.CurrentTensionRatio}"); + // var endNode = solver.GetEndNode(); + // if (endNode != null) + // { + // endNode.Body.isKinematic = false; + // } + // } + // } + } + } +} \ No newline at end of file diff --git a/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineTestController.cs.meta b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineTestController.cs.meta new file mode 100644 index 000000000..6506e03ba --- /dev/null +++ b/Assets/Scripts/Fishing/New/View/FishingLine/FishingLineTestController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 77af114bb80f3904a83cdeaacd5af508 \ No newline at end of file