using System; using System.Collections.Generic; using System.Text; using UnityEngine; namespace NBF { public enum LineType { Hand, HandDouble, Spinning, SpinningFloat, } public class FishingLineSolver : FGearBase { [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); } [SerializeField] public LineType LineType; [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); } } protected override void OnInit() { var tipRb = Rod.Asset.LineConnectorRigidbody; anchorTransform = tipRb.transform; } 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(); } /// /// 根据类型获取逻辑节点类型 /// /// /// public FishingLineNode GetLogicalNode(FishingLineNode.NodeType nodeType) { foreach (var fishingLineNode in logicalNodes) { if (fishingLineNode.Type == nodeType) { return fishingLineNode; } } return null; } // /// // /// 获取指定顺序索引的逻辑节点。 // /// 索引基于 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; } } }