using UnityEngine; namespace NBF { /// /// 简单水面接口。你可以替换成自己的水系统。 /// public interface IWaterSurfaceProvider { float GetWaterHeight(Vector3 worldPos); Vector3 GetWaterNormal(Vector3 worldPos); } /// /// 浮漂控制模式: /// 1. AirPhysics:空中/未入水,使用刚体物理 /// 2. WaterPresentation:入水后,关闭重力,Y 和旋转由脚本控制 /// public enum BobberControlMode { AirPhysics, WaterPresentation } /// /// 漂像事件类型 /// public enum BobberBiteType { None, Tap, // 轻点:快速下顿再回弹 MultiTap, // 连顿:连续多次下顿(鱼反复试探) SlowSink, // 缓沉:分段下沉,中途短暂停顿 Lift, // 送漂:上抬至高位后轻微晃动稳住 BlackDrift, // 黑漂:快速深沉并水平拖拽 Tremble // 微颤:高频小幅抖动(鱼在蹭饵试探) } public enum BobberPosture { Lying, // 躺漂 Tilted, // 斜漂 Upright // 立漂 } /// /// 入水后的内部阶段,可供外部查询或驱动 UI/音效 /// public enum BobberWaterPhase { Settling, // 刚入水,正在稳定 Stable, // 正常浮漂 Biting // 漂像动画中 } public struct BobberWaterConstraintState { public Vector3 TargetPosition; public float PlanarStrength; public float UpwardStrength; public float DownwardStrength; public float MaxCorrection; } [DisallowMultipleComponent] [RequireComponent(typeof(Rigidbody))] public class FLineNodeBobberProvider : MonoBehaviour, IFLineNodeConstraintProvider { [Header("Water")] [Tooltip("没有水提供器时使用固定水位")] public float fallbackWaterLevel = 0f; [Tooltip("可选:挂实现了 IWaterSurfaceProvider 的组件")] public MonoBehaviour waterProviderBehaviour; [Header("Enter Water")] [Tooltip("底部进入水面多少米后切换为漂像控制")] public float enterWaterDepth = 0.002f; [Tooltip("离开水面多少米后回到空中物理。一般给负值做滞回")] public float exitWaterDepth = -0.01f; [Header("Entry Animation")] [Tooltip("入水瞬间的初始下顿深度(米),形成自然入水感")] public float entryDipAmplitude = 0.012f; [Tooltip("入水稳定动画总时长(秒)")] public float entrySettleDuration = 0.8f; [Header("Air Hang")] [Tooltip("悬空时是否让浮漂长轴自然沿重力方向下挂")] public bool enableAirHangRotation = true; [Tooltip("悬空下垂旋转的跟随速度")] public float airHangRotationSpeed = 10f; [Tooltip("悬空时,上方支撑方向对平面朝向的影响权重。0=只看重力下垂,1=更多参考上方线段")] [Range(0f, 1f)] public float airHangLineInfluence = 0.75f; [Tooltip("悬空时优先让刚体本地重心方向朝向重力下方;重心方向不可用时才使用下面的手动轴")] public bool preferAirHangCenterOfMass = true; [UnityEngine.Serialization.FormerlySerializedAs("airHangUpAxisLocal")] [Tooltip("悬空时,浮漂本地哪个轴代表朝向底部/重心的长轴方向")] public Vector3 airHangDownAxisLocal = Vector3.down; [Tooltip("悬空时,用于确定绕长轴朝向的本地参考前向")] public Vector3 airHangForwardAxisLocal = Vector3.forward; [Header("Geometry")] [Tooltip("浮漂总高度(米)")] public float floatHeight = 0.08f; [Tooltip("如果 Pivot 在浮漂底部,这里填 0;如果 Pivot 在模型中心,就填底部相对 Pivot 的本地 Y")] public float bottomOffsetLocalY = 0f; [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("主水面起伏频率(Hz)")] public float surfaceBobFrequency = 1.2f; [Tooltip("次级起伏振幅比例(主振幅的倍率),使用黄金比例频率增加自然感")] [Range(0f, 1f)] public float surfaceBobSecondaryRatio = 0.35f; [Header("XZ Motion")] [Tooltip("入水后是否锁定 XZ 到入水点附近")] public bool lockXZAroundAnchor = true; [Tooltip("XZ 跟随平滑时间")] public float xzSmoothTime = 0.15f; [Tooltip("水流/拖拽带来的额外平面偏移最大值")] public float maxPlanarOffset = 0.15f; [Tooltip("移漂默认持续推力时间(秒)")] public float driftForceDuration = 0.45f; [Header("Water Constraint")] [Tooltip("接入 FLine 自定义约束时,使用求解器驱动水面目标,而不是脚本直接写世界坐标")] public bool useSolverWaterConstraint = true; [Tooltip("水面对目标 XZ 的平面跟随强度")] [Range(0f, 1f)] public float waterConstraintPlanarStrength = 0.12f; [Tooltip("浮漂低于目标水位时,向上托回去的强度")] [Range(0f, 1f)] public float waterConstraintUpwardStrength = 0.35f; [Tooltip("浮漂高于目标水位时,向下拉回去的强度。建议明显小于向上强度,这样鱼线才能把它拉出水面")] [Range(0f, 1f)] public float waterConstraintDownwardStrength = 0.05f; [Tooltip("每次约束迭代允许的最大修正位移(米)")] public float waterConstraintMaxCorrection = 0.01f; [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("平面拉力对立漂附加倾角的灵敏度(度/ratio)。值越大,斜向拉力对立漂的影响越明显")] public float planarTiltFactor = 120f; [Tooltip("平面方向死区,小于该值时保持上一帧方向")] public float planarDirectionDeadZone = 0.01f; [Tooltip("平面方向平滑速度")] public float planarDirectionLerpSpeed = 10f; [Tooltip("最终旋转 Slerp 速度")] public float rotationLerpSpeed = 8f; [Header("Posture Blend")] [Tooltip("躺漂 ↔ 斜漂 角度过渡平滑时间")] public float postureBlendSmoothTime = 0.15f; [Tooltip("斜漂 ↔ 立漂 角度过渡平滑时间(通常稍慢,立漂更庄重)")] public float postureBlendSmoothTimeUpright = 0.22f; [Header("Posture Wobble")] [Tooltip("姿态切换时是否启用旋转回弹抖动")] public bool enablePostureWobble = true; [Tooltip("切换触发的旋转抖动初始角速度(度/秒)")] public float postureWobbleStrength = 10f; [Tooltip("旋转抖动弹簧衰减速率。越大衰减越快,值等于 sqrt(k) 的临界阻尼系数")] public float postureWobbleDecay = 7f; [Header("Debug Input")] [Tooltip("调试:按 R 停止漂像")] public bool debugResetKey = true; [Tooltip("调试:按 T 触发轻点")] public bool debugTapKey = true; [Tooltip("调试:按 Y 触发连顿")] public bool debugMultiTapKey = true; [Tooltip("调试:按 G 触发缓沉")] public bool debugSlowSinkKey = true; [Tooltip("调试:按 H 触发送漂")] public bool debugLiftKey = true; [Tooltip("调试:按 B 触发黑漂")] public bool debugBlackDriftKey = true; [Tooltip("调试:按 V 触发微颤")] public bool debugTrembleKey = true; [Tooltip("调试:按 N 触发移漂(向 transform.forward 移动 0.1m)")] public bool debugDriftKey = true; [Header("Debug")] public bool drawDebug = false; public bool UseTestPosture; public BobberPosture TestPosture; // ── 只读公开状态 ────────────────────────────────────────────── public BobberControlMode CurrentMode => _mode; public BobberPosture CurrentPosture => _posture; public BobberWaterPhase CurrentWaterPhase => _waterPhase; public float CurrentVerticalRatio => _verticalRatio; public float CurrentPlanarRatio => _planarRatio; public bool UsesSolverWaterConstraint => useSolverWaterConstraint && _logicNode != null; /// 连续姿态混合值:0=躺漂, 1=斜漂, 2=立漂。可直接驱动外部动画混合树。 public float PostureBlendValue => _postureBlendValue; // ── 外部可写输入 ─────────────────────────────────────────────── /// 等效向下拉力(不必须是真实力,作为输入信号即可) public float ExternalDownForce { get; set; } /// 是否触底 public bool IsBottomTouched { get; set; } /// 额外平面偏移(例如风、水流、拖拽) public Vector2 ExternalPlanarOffset { get; set; } // ── 事件 ────────────────────────────────────────────────────── /// 姿态切换完成时触发:(旧姿态, 新姿态) public event System.Action OnPostureChanged; public bool TryGetWaterConstraintState(out BobberWaterConstraintState state) { state = default; if (!UsesSolverWaterConstraint || _mode != BobberControlMode.WaterPresentation) return false; state.TargetPosition = _constraintTargetPosition; state.PlanarStrength = Mathf.Clamp01(waterConstraintPlanarStrength); state.UpwardStrength = Mathf.Clamp01(waterConstraintUpwardStrength); state.DownwardStrength = Mathf.Clamp01(waterConstraintDownwardStrength); state.MaxCorrection = Mathf.Max(0f, waterConstraintMaxCorrection); return true; } public bool TryGetConstraintState(out FLineNodeConstraintState state) { state = default; if (!TryGetWaterConstraintState(out BobberWaterConstraintState waterState)) return false; state.TargetPosition = waterState.TargetPosition; state.PlanarStrength = waterState.PlanarStrength; state.UpwardStrength = waterState.UpwardStrength; state.DownwardStrength = waterState.DownwardStrength; state.MaxCorrection = waterState.MaxCorrection; return true; } // ── 核心私有字段 ─────────────────────────────────────────────── private Rigidbody _rb; private FLineLogicNode _logicNode; private IWaterSurfaceProvider _waterProvider; private BobberControlMode _mode = BobberControlMode.AirPhysics; private BobberPosture _posture = BobberPosture.Lying; private BobberWaterPhase _waterPhase = BobberWaterPhase.Stable; private float _defaultLinearDamping; private float _defaultAngularDamping; private bool _defaultUseGravity; private Vector3 _waterAnchorPos; private Vector3 _xzSmoothVelocity; private float _ySmoothVelocity; private float _biteOffsetY; private float _biteOffsetYVelocity; private Quaternion _targetRotation; private Vector3 _constraintTargetPosition; // 漂像运行时 private BobberBiteType _activeBiteType = BobberBiteType.None; private float _biteTimer; private float _biteDuration; private float _biteAmplitude; private Vector3 _blackDriftDirection; private int _multiTapTotalCount; private float _trembleRuntimeFreq = 10f; // 姿态判断运行时 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; // 姿态平滑混合(0=躺, 1=斜, 2=立) private float _postureBlendValue; private float _postureBlendVelocity; // 旋转回弹弹簧(spring-damper) private float _rotWobble; private float _rotWobbleVel; // 入水稳定动画 private float _entrySettleTimer; private float _entrySettleOffsetY; // 移漂运行时:驱动水面锚点在 XZ 平面移动到目标距离后停下 private bool _isDriftForceActive; private Vector3 _driftStartAnchorPos; private Vector3 _driftForceDirection; private float _driftForceTimer; private float _driftForceDuration; // 水面起伏实例随机相位 private float _bobPhaseOffset; // Crest 波面查询 private bool _hasCrestSampleThisFrame; private readonly Vector3[] _waterQueryPoints = new Vector3[1]; private readonly Vector3[] _waterQueryResultDisplacements = new Vector3[1]; private readonly Vector3[] _waterQueryResultVelocities = new Vector3[1]; private readonly Vector3[] _waterQueryResultNormal = new Vector3[1]; private void Awake() { _rb = GetComponent(); _logicNode = GetComponent(); _defaultLinearDamping = _rb.linearDamping; _defaultAngularDamping = _rb.angularDamping; _defaultUseGravity = _rb.useGravity; if (waterProviderBehaviour != null) _waterProvider = waterProviderBehaviour as IWaterSurfaceProvider; _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; _constraintTargetPosition = transform.position; _postureBlendValue = PostureToBlend(_posture); // 随机相位,避免多个浮漂同步起伏 _bobPhaseOffset = Random.value * Mathf.PI * 2f; } private void Update() { HandleDebugKeys(); } private void FixedUpdate() { float waterY = GetWaterHeight(transform.position); Vector3 bottomWorld = GetBottomWorldPosition(); float submergeDepth = waterY - bottomWorld.y; switch (_mode) { case BobberControlMode.AirPhysics: UpdateAirPhysics(waterY, submergeDepth); break; case BobberControlMode.WaterPresentation: UpdateWaterPresentation(waterY, submergeDepth); break; } if (drawDebug) DrawDebug(waterY); } #region Main Update private void UpdateAirPhysics(float waterY, float submergeDepth) { RestoreAirPhysicsState(); UpdateAirHangRotation(); if (submergeDepth > enterWaterDepth) EnterWaterPresentationMode(waterY); } private void UpdateWaterPresentation(float waterY, float submergeDepth) { if (submergeDepth < exitWaterDepth) { ExitWaterPresentationMode(); return; } if (UsesSolverWaterConstraint) { UpdateSolverDrivenWaterPresentation(waterY); return; } // 入水后由脚本/约束统一驱动目标位姿,刚体速度压制掉避免额外干扰 _rb.useGravity = false; _rb.linearVelocity = Vector3.zero; _rb.angularVelocity = Vector3.zero; _rb.linearDamping = 999f; _rb.angularDamping = 999f; TickWaterPresentationState(); Vector3 pos = transform.position; // 1. 目标 Y float 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, Time.fixedDeltaTime ); } // 2. 目标 XZ Vector3 targetXZ = CalculateTargetXZ(); Vector3 planarPos = new Vector3(pos.x, 0f, pos.z); Vector3 planarTarget = new Vector3(targetXZ.x, 0f, targetXZ.z); planarPos = Vector3.SmoothDamp( planarPos, planarTarget, ref _xzSmoothVelocity, Mathf.Max(0.0001f, xzSmoothTime), Mathf.Infinity, Time.fixedDeltaTime ); pos.x = planarPos.x; pos.z = planarPos.z; transform.position = pos; _constraintTargetPosition = pos; UpdateWaterPresentationRotation(waterY); } private void UpdateSolverDrivenWaterPresentation(float waterY) { _rb.useGravity = false; _rb.angularVelocity = Vector3.zero; _rb.linearDamping = _defaultLinearDamping; _rb.angularDamping = _defaultAngularDamping; TickWaterPresentationState(); UpdateConstraintTargetPosition(waterY); UpdateWaterPresentationRotation(waterY); } private void TickWaterPresentationState() { if (_entrySettleTimer > 0f) { _entrySettleTimer -= Time.fixedDeltaTime; if (_entrySettleTimer <= 0f) { _entrySettleTimer = 0f; _waterPhase = _activeBiteType != BobberBiteType.None ? BobberWaterPhase.Biting : BobberWaterPhase.Stable; } } UpdateBiteAnimation(); UpdateDriftForce(); UpdateRotationWobble(); } private void UpdateConstraintTargetPosition(float waterY) { Vector3 pos = _constraintTargetPosition; float 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, Time.fixedDeltaTime ); } Vector3 targetXZ = CalculateTargetXZ(); Vector3 planarPos = new Vector3(pos.x, 0f, pos.z); Vector3 planarTarget = new Vector3(targetXZ.x, 0f, targetXZ.z); planarPos = Vector3.SmoothDamp( planarPos, planarTarget, ref _xzSmoothVelocity, Mathf.Max(0.0001f, xzSmoothTime), Mathf.Infinity, Time.fixedDeltaTime ); pos.x = planarPos.x; pos.z = planarPos.z; _constraintTargetPosition = pos; } private void UpdateWaterPresentationRotation(float waterY) { EvaluatePostureByComponents(waterY); UpdateTargetRotationByPosture(); transform.rotation = Quaternion.Slerp( transform.rotation, _targetRotation, 1f - Mathf.Exp(-rotationLerpSpeed * Time.fixedDeltaTime) ); } #endregion #region Mode Switch private void EnterWaterPresentationMode(float waterY) { _mode = BobberControlMode.WaterPresentation; _waterAnchorPos = transform.position; _constraintTargetPosition = transform.position; _ySmoothVelocity = 0f; _xzSmoothVelocity = Vector3.zero; _biteOffsetY = 0f; _biteOffsetYVelocity = 0f; _activeBiteType = BobberBiteType.None; _biteTimer = 0f; // 启动入水稳定动画 _entrySettleTimer = Mathf.Max(0f, entrySettleDuration); _entrySettleOffsetY = CalculateInitialEntrySettleOffset(waterY); _waterPhase = BobberWaterPhase.Settling; // 重置姿态至躺漂 _posture = BobberPosture.Lying; _verticalRatio = 0f; _planarRatio = 0f; _verticalDistance = 0f; _planarDistance = 0f; _pendingPosture = _posture; _pendingPostureTimer = 0f; _postureCooldownTimer = 0f; _postureBlendValue = PostureToBlend(_posture); _postureBlendVelocity = 0f; // 重置旋转弹簧 _rotWobble = 0f; _rotWobbleVel = 0f; // 重置移漂 _isDriftForceActive = false; _driftStartAnchorPos = _waterAnchorPos; _driftForceDirection = Vector3.zero; _driftForceTimer = 0f; _driftForceDuration = 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; _waterPhase = BobberWaterPhase.Stable; _constraintTargetPosition = transform.position; _entrySettleOffsetY = 0f; RestoreAirPhysicsState(); } private void RestoreAirPhysicsState() { _rb.useGravity = _defaultUseGravity; _rb.linearDamping = _defaultLinearDamping; _rb.angularDamping = _defaultAngularDamping; } private void UpdateAirHangRotation() { if (!enableAirHangRotation || !_rb || _rb.isKinematic) return; Vector3 gravity = Physics.gravity; if (gravity.sqrMagnitude < 1e-6f) return; Vector3 gravityDown = gravity.normalized; Vector3 gravityUp = -gravityDown; Vector3 supportUp = gravityUp; bool hasUpperSupport = TryGetAirHangSupportDirection(gravityUp, out supportUp); Vector3 targetDown = gravityDown; Vector3 forwardHint = Vector3.zero; if (hasUpperSupport) { Vector3 supportPlanar = Vector3.ProjectOnPlane(supportUp, targetDown); if (supportPlanar.sqrMagnitude > 1e-6f) { supportPlanar.Normalize(); Vector3 stablePlanar = Vector3.ProjectOnPlane(_stablePlanarDir, targetDown); if (stablePlanar.sqrMagnitude < 1e-6f) stablePlanar = supportPlanar; else stablePlanar.Normalize(); forwardHint = Vector3.Slerp(stablePlanar, supportPlanar, Mathf.Clamp01(airHangLineInfluence)); } } if (forwardHint.sqrMagnitude < 1e-6f) forwardHint = Vector3.ProjectOnPlane(_stablePlanarDir, targetDown); if (forwardHint.sqrMagnitude < 1e-6f) forwardHint = Vector3.ProjectOnPlane(transform.forward, targetDown); if (forwardHint.sqrMagnitude < 1e-6f) forwardHint = Vector3.ProjectOnPlane(Vector3.forward, targetDown); if (forwardHint.sqrMagnitude < 1e-6f) forwardHint = Vector3.ProjectOnPlane(Vector3.right, targetDown); forwardHint.Normalize(); if (_stablePlanarDir.sqrMagnitude > 1e-6f && Vector3.Dot(forwardHint, _stablePlanarDir) < 0f) forwardHint = -forwardHint; Vector3 localDown = ResolveAirHangDownAxisLocal(); Quaternion targetRotation = BuildAxisAlignedRotation(targetDown, forwardHint, localDown); float blend = 1f - Mathf.Exp(-Mathf.Max(0.01f, airHangRotationSpeed) * Time.fixedDeltaTime); Quaternion nextRotation = Quaternion.Slerp(_rb.rotation, targetRotation, blend); _rb.angularVelocity = Vector3.zero; _rb.MoveRotation(nextRotation); Vector3 projectedForward = Vector3.ProjectOnPlane(nextRotation * Vector3.forward, targetDown); if (projectedForward.sqrMagnitude > 1e-6f) _stablePlanarDir = projectedForward.normalized; } private bool TryGetAirHangSupportDirection(Vector3 gravityUp, out Vector3 supportUp) { supportUp = gravityUp; if (_logicNode == null || !_logicNode.TryGetAdjacentBodies(out Rigidbody previousBody, out Rigidbody nextBody)) return false; Vector3 currentPos = _rb.worldCenterOfMass; Vector3 upperDirection = Vector3.zero; float bestUpperDot = float.NegativeInfinity; TryConsumeNeighbor(previousBody, currentPos, gravityUp, ref upperDirection, ref bestUpperDot); TryConsumeNeighbor(nextBody, currentPos, gravityUp, ref upperDirection, ref bestUpperDot); if (upperDirection.sqrMagnitude > 1e-6f && bestUpperDot > 0.05f) { supportUp = upperDirection; return true; } return false; } private static void TryConsumeNeighbor( Rigidbody neighborBody, Vector3 currentPos, Vector3 gravityUp, ref Vector3 upperDirection, ref float bestUpperDot) { if (!neighborBody) return; Vector3 toNeighbor = neighborBody.worldCenterOfMass - currentPos; if (toNeighbor.sqrMagnitude < 1e-6f) return; Vector3 dir = toNeighbor.normalized; float upDot = Vector3.Dot(dir, gravityUp); if (upDot > bestUpperDot) { bestUpperDot = upDot; upperDirection = dir; } } private Vector3 ResolveAirHangDownAxisLocal() { if (preferAirHangCenterOfMass && _rb) { Vector3 centerOfMassLocal = _rb.centerOfMass; if (centerOfMassLocal.sqrMagnitude > 1e-6f) return centerOfMassLocal.normalized; } return airHangDownAxisLocal.sqrMagnitude > 1e-6f ? airHangDownAxisLocal.normalized : Vector3.down; } private Quaternion BuildAxisAlignedRotation(Vector3 targetDown, Vector3 forwardHint, Vector3 localDownAxis) { Vector3 safeDown = targetDown.sqrMagnitude > 1e-6f ? targetDown.normalized : Vector3.down; Vector3 safeUp = -safeDown; Vector3 safeForward = Vector3.ProjectOnPlane(forwardHint, safeDown); if (safeForward.sqrMagnitude < 1e-6f) safeForward = Vector3.ProjectOnPlane(Vector3.forward, safeDown); if (safeForward.sqrMagnitude < 1e-6f) safeForward = Vector3.ProjectOnPlane(Vector3.right, safeDown); safeForward.Normalize(); Vector3 localDown = localDownAxis.sqrMagnitude > 1e-6f ? localDownAxis.normalized : Vector3.down; Vector3 localUp = -localDown; Vector3 localForward = Vector3.ProjectOnPlane(airHangForwardAxisLocal, localDown); if (localForward.sqrMagnitude < 1e-6f) localForward = Vector3.ProjectOnPlane(Vector3.forward, localDown); if (localForward.sqrMagnitude < 1e-6f) localForward = Vector3.ProjectOnPlane(Vector3.right, localDown); localForward.Normalize(); Quaternion worldBasis = Quaternion.LookRotation(safeForward, safeUp); Quaternion localBasis = Quaternion.LookRotation(localForward, localUp); return worldBasis * Quaternion.Inverse(localBasis); } private float CalculateInitialEntrySettleOffset(float waterY) { float baseTargetY = CalculateBaseTargetY(waterY); float currentOffset = Mathf.Min(0f, transform.position.y - baseTargetY); float configuredDip = -Mathf.Max(0f, entryDipAmplitude); return Mathf.Min(currentOffset, configuredDip); } #endregion #region Target Calculation private float CalculateTargetY(float waterY) { float targetPivotY = CalculateBaseTargetY(waterY); if (_entrySettleTimer <= 0f || entrySettleDuration <= 0f) return targetPivotY; float phase = 1f - Mathf.Clamp01(_entrySettleTimer / Mathf.Max(0.001f, entrySettleDuration)); float riseT = Mathf.SmoothStep(0f, 1f, phase); return targetPivotY + Mathf.Lerp(_entrySettleOffsetY, 0f, riseT); } private float CalculateBaseTargetY(float waterY) { float baseSinkDepth = floatHeight * Mathf.Clamp01(baseSubmergeRatio); float sinkByForce = Mathf.Clamp( ExternalDownForce * downForceToSink, 0f, maxExtraSink ); float bottomAdjust = 0f; if (enableBottomTouchAdjust && IsBottomTouched) bottomAdjust -= bottomTouchLift; // 多层水面起伏:主波 + 次波(黄金比例频率),各实例相位随机 float surfaceBob = 0f; if (enableSurfaceBobbing && !_hasCrestSampleThisFrame) { float t = Time.time; float primary = Mathf.Sin(t * surfaceBobFrequency * Mathf.PI * 2f + _bobPhaseOffset) * surfaceBobAmplitude; float secondary = Mathf.Sin(t * surfaceBobFrequency * 1.618f * Mathf.PI * 2f + _bobPhaseOffset * 2.1f) * surfaceBobAmplitude * surfaceBobSecondaryRatio; surfaceBob = primary + secondary; } float totalSink = baseSinkDepth + sinkByForce + bottomAdjust; float targetBottomY = waterY - totalSink; float targetPivotY = targetBottomY - bottomOffsetLocalY + surfaceBob + _biteOffsetY; return targetPivotY; } private Vector3 CalculateTargetXZ() { Vector2 planarOffset = Vector2.ClampMagnitude(ExternalPlanarOffset, maxPlanarOffset); Vector3 basePos = lockXZAroundAnchor || _isDriftForceActive ? _waterAnchorPos : transform.position; if (_activeBiteType == BobberBiteType.BlackDrift) { float t = Mathf.Clamp01(_biteDuration > 0f ? _biteTimer / _biteDuration : 1f); float drift = Mathf.SmoothStep(0f, 1f, t) * 0.08f; Vector3 bd = _blackDriftDirection * drift; basePos += new Vector3(bd.x, 0f, bd.z); } return new Vector3( basePos.x + planarOffset.x, transform.position.y, basePos.z + planarOffset.y ); } private void EvaluatePostureByComponents(float waterY) { float submergeRatio = Mathf.Clamp01( (waterY - GetBottomWorldPosition().y) / Mathf.Max(0.0001f, floatHeight) ); bool hasLure = lureBody != null; if (!hasLure) { _verticalDistance = 0f; _planarDistance = 0f; _verticalRatio = 0f; _planarRatio = 0f; } else { Vector3 bobberPos = _rb.worldCenterOfMass; Vector3 lurePos = lureBody.worldCenterOfMass; Vector3 delta = lurePos - bobberPos; _verticalDistance = Mathf.Max(0f, Vector3.Dot(delta, Vector3.down)); _planarDistance = Vector3.ProjectOnPlane(delta, Vector3.up).magnitude; float refLen = Mathf.Max(0.0001f, referenceLength); _verticalRatio = _verticalDistance / refLen; _planarRatio = _planarDistance / refLen; } BobberPosture desired = DeterminePostureState(submergeRatio, hasLure); ApplyPostureWithStability(desired); } /// /// 姿态状态判断的唯一入口。改规则只改这里,外部负责数据采样和旋转平滑。 /// private BobberPosture DeterminePostureState(float submergeRatio, bool hasLure) { if (UseTestPosture) return TestPosture; // 无 Lure 时的兜底规则 if (!hasLure) { if (submergeRatio < minSubmergeToStand) return BobberPosture.Lying; if (ExternalPlanarOffset.magnitude > 0.01f) return BobberPosture.Tilted; return BobberPosture.Upright; } // 带滞回的状态机:从当前状态出发,减少来回切换 switch (_posture) { case BobberPosture.Lying: { bool canStandUpright = submergeRatio >= minSubmergeToStand && _verticalRatio > verticalUprightThreshold + postureHysteresis && _planarRatio < planarTiltThreshold - postureHysteresis; bool canTilt = submergeRatio >= minSubmergeToStand * 0.8f && _verticalRatio > verticalLieThreshold + postureHysteresis; if (canStandUpright) return BobberPosture.Upright; if (canTilt) return BobberPosture.Tilted; return BobberPosture.Lying; } case BobberPosture.Tilted: { bool shouldLie = submergeRatio < minSubmergeToStand * 0.75f || _verticalRatio < verticalLieThreshold - postureHysteresis || _planarDistance > _verticalDistance * planarDominanceMultiplier; bool shouldStand = submergeRatio >= minSubmergeToStand && _verticalRatio > verticalUprightThreshold + postureHysteresis && _planarRatio < planarTiltThreshold - postureHysteresis; if (shouldLie) return BobberPosture.Lying; if (shouldStand) return BobberPosture.Upright; return BobberPosture.Tilted; } default: // Upright { bool shouldLie = submergeRatio < minSubmergeToStand * 0.75f || _verticalRatio < verticalLieThreshold - postureHysteresis || _planarDistance > _verticalDistance * (planarDominanceMultiplier + 0.15f); bool 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) { _postureCooldownTimer = Mathf.Max(0f, _postureCooldownTimer - Time.fixedDeltaTime); 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 += Time.fixedDeltaTime; if (_pendingPostureTimer >= Mathf.Max(0f, postureConfirmTime)) { BobberPosture oldPosture = _posture; _posture = desiredPosture; _pendingPosture = _posture; _pendingPostureTimer = 0f; _postureCooldownTimer = Mathf.Max(0f, postureSwitchCooldown); OnPostureTransition(oldPosture, _posture); } } /// /// 姿态切换完成时:触发外部事件,并向弹簧注入回弹初速。 /// private void OnPostureTransition(BobberPosture from, BobberPosture to) { OnPostureChanged?.Invoke(from, to); if (!enablePostureWobble) return; // (int)Lying=0 < Tilted=1 < Upright=2 // 向更立漂方向切换 → 角度减小 → 初始抖动为负(先过冲再弹回) // 向更躺漂方向切换 → 角度增大 → 初始抖动为正 float sign = (int)to > (int)from ? -1f : 1f; _rotWobbleVel += sign * postureWobbleStrength; } private void UpdateTargetRotationByPosture() { // ── 更新平面稳定方向 ──────────────────────────────────────── Vector3 candidateDir = Vector3.zero; if (lureBody != null) candidateDir = Vector3.ProjectOnPlane( lureBody.worldCenterOfMass - _rb.worldCenterOfMass, 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(); float dirDeadZone = Mathf.Max(0.0001f, planarDirectionDeadZone); if (candidateDir.sqrMagnitude > dirDeadZone * dirDeadZone) { candidateDir.Normalize(); // 避免 180° 翻转闪烁 if (Vector3.Dot(candidateDir, _stablePlanarDir) < 0f) candidateDir = -candidateDir; float k = 1f - Mathf.Exp(-Mathf.Max(0.01f, planarDirectionLerpSpeed) * Time.fixedDeltaTime); _stablePlanarDir = Vector3.Slerp(_stablePlanarDir, candidateDir, k); _stablePlanarDir.Normalize(); } // ── 姿态混合值平滑(0=躺, 1=斜, 2=立)────────────────────── float targetBlend = PostureToBlend(_posture); // 躺↔斜 用较短时间,斜↔立 用较长时间(立漂更稳重) bool involveUpright = (_postureBlendValue > 1f) || (targetBlend > 1f); float blendTime = involveUpright ? postureBlendSmoothTimeUpright : postureBlendSmoothTime; _postureBlendValue = Mathf.SmoothDamp( _postureBlendValue, targetBlend, ref _postureBlendVelocity, Mathf.Max(0.001f, blendTime), Mathf.Infinity, Time.fixedDeltaTime ); // ── 从混合值计算目标倾角 ──────────────────────────────────── float baseAngle; if (_postureBlendValue <= 1f) { // 躺漂(lyingAngle) ↔ 斜漂(tiltedAngle) baseAngle = Mathf.Lerp(lyingAngle, tiltedAngle, _postureBlendValue); } else { // 斜漂(tiltedAngle) ↔ 立漂(uprightTilt) // 立漂时根据平面拉力给出微小附加倾角,planarTiltFactor 控制灵敏度 float uprightTilt = Mathf.Clamp(_planarRatio * planarTiltFactor, 0f, uprightMaxTiltAngle); baseAngle = Mathf.Lerp(tiltedAngle, uprightTilt, _postureBlendValue - 1f); } // 叠加旋转回弹抖动 float finalAngle = baseAngle + _rotWobble; // ── 构建目标旋转 ───────────────────────────────────────────── Vector3 tiltAxis = Vector3.Cross(Vector3.up, _stablePlanarDir); if (tiltAxis.sqrMagnitude < 1e-6f) tiltAxis = transform.right; _targetRotation = Quaternion.AngleAxis(finalAngle, tiltAxis.normalized); } /// /// 临界阻尼弹簧:k = decay², damper = 2*decay,每帧调用以衰减旋转抖动。 /// private void UpdateRotationWobble() { if (Mathf.Abs(_rotWobble) < 0.01f && Mathf.Abs(_rotWobbleVel) < 0.01f) { _rotWobble = 0f; _rotWobbleVel = 0f; return; } float d = Mathf.Max(0.01f, postureWobbleDecay); float spring = -(d * d) * _rotWobble; float damper = -(2f * d) * _rotWobbleVel; _rotWobbleVel += (spring + damper) * Time.fixedDeltaTime; _rotWobble += _rotWobbleVel * Time.fixedDeltaTime; } #endregion #region Bite Presentation /// 轻点:快速下顿再回弹 public void PlayTap(float amplitude = 0.008f, float duration = 0.18f) { StartBite(BobberBiteType.Tap, amplitude, duration); } /// 连顿:count 次连续下顿,每顿之间轻微回弹,振幅略微衰减 public void PlayMultiTap(int count = 3, float amplitude = 0.006f, float intervalPerTap = 0.22f) { _multiTapTotalCount = Mathf.Max(1, count); StartBite(BobberBiteType.MultiTap, amplitude, intervalPerTap * _multiTapTotalCount); } /// 缓沉:分段下沉,40% 处有短暂停顿感(鱼吞饵试探) 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 PlayTremble(float amplitude = 0.004f, float duration = 1.5f, float frequency = 10f) { _trembleRuntimeFreq = Mathf.Max(1f, frequency); StartBite(BobberBiteType.Tremble, amplitude, duration); } /// 移漂:按给定世界方向在 XZ 平面平移指定距离。 public void PlayDrift(Vector3 direction, float distance) { PlayDrift(direction, distance, driftForceDuration); } /// 移漂:沿给定方向在 XZ 平面移动指定距离,并在目标点停下。 public void PlayDrift(Vector3 direction, float distance, float duration) { if (_mode != BobberControlMode.WaterPresentation) return; Vector3 planarDirection = Vector3.ProjectOnPlane(direction, Vector3.up); if (planarDirection.sqrMagnitude < 1e-6f || Mathf.Approximately(distance, 0f)) return; float safeDuration = Mathf.Max(0.02f, duration); SyncWaterAnchorToCurrentXZ(); _driftStartAnchorPos = _waterAnchorPos; _driftForceDirection = planarDirection.normalized * distance; _driftForceTimer = 0f; _driftForceDuration = safeDuration; _isDriftForceActive = true; _xzSmoothVelocity = Vector3.zero; } public void StopDrift() { _isDriftForceActive = false; _driftForceTimer = 0f; _driftForceDuration = 0f; _driftForceDirection = Vector3.zero; _xzSmoothVelocity = Vector3.zero; SyncWaterAnchorToCurrentXZ(); } public void StopBite() { _activeBiteType = BobberBiteType.None; _biteTimer = 0f; _biteDuration = 0f; _biteAmplitude = 0f; _biteOffsetY = 0f; _biteOffsetYVelocity = 0f; if (_waterPhase == BobberWaterPhase.Biting) _waterPhase = BobberWaterPhase.Stable; } private void UpdateDriftForce() { if (!_isDriftForceActive) return; _driftForceTimer += Time.fixedDeltaTime; float t = Mathf.Clamp01(_driftForceTimer / Mathf.Max(0.0001f, _driftForceDuration)); float easedT = Mathf.SmoothStep(0f, 1f, t); _waterAnchorPos = _driftStartAnchorPos + _driftForceDirection * easedT; if (t < 1f) return; _waterAnchorPos = _driftStartAnchorPos + _driftForceDirection; _isDriftForceActive = false; _driftForceTimer = 0f; _driftForceDuration = 0f; _driftForceDirection = Vector3.zero; _xzSmoothVelocity = Vector3.zero; } 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; if (_waterPhase != BobberWaterPhase.Settling) _waterPhase = BobberWaterPhase.Biting; } private void UpdateBiteAnimation() { if (_activeBiteType == BobberBiteType.None) { _biteOffsetY = Mathf.SmoothDamp( _biteOffsetY, 0f, ref _biteOffsetYVelocity, 0.08f, Mathf.Infinity, Time.fixedDeltaTime ); return; } _biteTimer += Time.fixedDeltaTime; float t = Mathf.Clamp01(_biteTimer / _biteDuration); float targetOffset = 0f; switch (_activeBiteType) { case BobberBiteType.Tap: { // 前 35% 快速下顿,后 65% 回弹 targetOffset = t < 0.35f ? -Mathf.SmoothStep(0f, _biteAmplitude, t / 0.35f) : -Mathf.Lerp(_biteAmplitude, 0f, (t - 0.35f) / 0.65f); break; } case BobberBiteType.MultiTap: { // 每顿时间均分,每顿内模拟单次 Tap 曲线,振幅随顿次轻微衰减 int totalTaps = Mathf.Max(1, _multiTapTotalCount); float tapLength = 1f / totalTaps; float cycleT = Mathf.Repeat(t, tapLength) / tapLength; float tapIndex = Mathf.Floor(t / tapLength); float decay = 1f - tapIndex / totalTaps * 0.25f; // 末顿约 75% 振幅 targetOffset = cycleT < 0.35f ? -Mathf.SmoothStep(0f, _biteAmplitude * decay, cycleT / 0.35f) : -Mathf.Lerp(_biteAmplitude * decay, 0f, (cycleT - 0.35f) / 0.65f); break; } case BobberBiteType.SlowSink: { // 三段式缓沉:0~40% 渐沉至一半,40~60% 短暂停顿,60~100% 继续下沉 float sinkOffset; if (t < 0.4f) sinkOffset = Mathf.SmoothStep(0f, _biteAmplitude * 0.5f, t / 0.4f); else if (t < 0.6f) sinkOffset = _biteAmplitude * 0.5f; else sinkOffset = Mathf.SmoothStep(_biteAmplitude * 0.5f, _biteAmplitude, (t - 0.6f) / 0.4f); targetOffset = -sinkOffset; break; } case BobberBiteType.Lift: { // 前 60% 平滑送起,60%~100% 顶部轻微振荡后稳住 if (t < 0.6f) { targetOffset = Mathf.SmoothStep(0f, _biteAmplitude, t / 0.6f); } else { float wobbleT = (t - 0.6f) / 0.4f; float wobble = Mathf.Sin(wobbleT * Mathf.PI * 3f) * _biteAmplitude * 0.12f * (1f - wobbleT); targetOffset = _biteAmplitude + wobble; } break; } case BobberBiteType.BlackDrift: { // 前 60% 快速深沉,之后维持最大深度 float sinkT = Mathf.SmoothStep(0f, 1f, Mathf.Min(t / 0.6f, 1f)); targetOffset = -_biteAmplitude * sinkT; break; } case BobberBiteType.Tremble: { // 高频振荡,振幅线性衰减至 0 float decay2 = 1f - t; targetOffset = Mathf.Sin(Time.time * _trembleRuntimeFreq * Mathf.PI * 2f) * _biteAmplitude * decay2; break; } } // Tremble 需要快速响应,其余用标准平滑时间 float smoothTime = _activeBiteType == BobberBiteType.Tremble ? 0.01f : 0.03f; _biteOffsetY = Mathf.SmoothDamp( _biteOffsetY, targetOffset, ref _biteOffsetYVelocity, smoothTime, Mathf.Infinity, Time.fixedDeltaTime ); // 持续类漂像(缓沉/黑漂)到达终点后不自动结束,等外部调用 StopBite if (_biteTimer >= _biteDuration) { if (_activeBiteType == BobberBiteType.SlowSink || _activeBiteType == BobberBiteType.BlackDrift) return; _activeBiteType = BobberBiteType.None; if (_waterPhase == BobberWaterPhase.Biting) _waterPhase = BobberWaterPhase.Stable; } } #endregion #region Utilities private static float PostureToBlend(BobberPosture posture) => posture switch { BobberPosture.Lying => 0f, BobberPosture.Tilted => 1f, _ => 2f }; private float GetWaterHeight(Vector3 worldPos) { if (_waterProvider != null) { _hasCrestSampleThisFrame = false; return _waterProvider.GetWaterHeight(worldPos); } _hasCrestSampleThisFrame = false; return fallbackWaterLevel; } private Vector3 GetBottomWorldPosition() { return transform.TransformPoint(new Vector3(0f, bottomOffsetLocalY, 0f)); } private void SyncWaterAnchorToCurrentXZ() { Vector2 planarOffset = Vector2.ClampMagnitude(ExternalPlanarOffset, maxPlanarOffset); Vector3 pos = transform.position; _waterAnchorPos = new Vector3( pos.x - planarOffset.x, _waterAnchorPos.y, pos.z - planarOffset.y ); } private void HandleDebugKeys() { if (!Application.isPlaying) return; if (debugResetKey && Input.GetKeyDown(KeyCode.R)) StopBite(); if (debugTapKey && Input.GetKeyDown(KeyCode.T)) PlayTap(); if (debugMultiTapKey && Input.GetKeyDown(KeyCode.Y)) PlayMultiTap(); if (debugSlowSinkKey && Input.GetKeyDown(KeyCode.G)) PlaySlowSink(); if (debugLiftKey && Input.GetKeyDown(KeyCode.H)) PlayLift(); if (debugBlackDriftKey && Input.GetKeyDown(KeyCode.B)) PlayBlackDrift(); if (debugTrembleKey && Input.GetKeyDown(KeyCode.V)) PlayTremble(); if (debugDriftKey && Input.GetKeyDown(KeyCode.N)) PlayDrift(transform.forward, 0.1f); } private void DrawDebug(float waterY) { Vector3 p = transform.position; Vector3 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.AirPhysics && enableAirHangRotation) { Vector3 hangDown = transform.TransformDirection(ResolveAirHangDownAxisLocal()); Debug.DrawLine(p, p + hangDown * 0.08f, Color.gray); } if (_mode == BobberControlMode.WaterPresentation) { Vector3 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); // 姿态混合值可视化:白线高度代表 PostureBlendValue(0~2) Debug.DrawLine(p, p + Vector3.up * (_postureBlendValue * 0.02f), Color.white); if (UsesSolverWaterConstraint) { Debug.DrawLine(p, _constraintTargetPosition, Color.cyan); Debug.DrawLine( _constraintTargetPosition + Vector3.left * 0.02f, _constraintTargetPosition + Vector3.right * 0.02f, Color.cyan ); } } if (lureBody != null) { Vector3 bobber = _rb.worldCenterOfMass; Vector3 lure = lureBody.worldCenterOfMass; Debug.DrawLine(bobber, lure, Color.magenta); Vector3 verticalEnd = bobber + Vector3.down * _verticalDistance; Debug.DrawLine(bobber, verticalEnd, Color.red); Vector3 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); airHangRotationSpeed = Mathf.Max(0.01f, airHangRotationSpeed); airHangLineInfluence = Mathf.Clamp01(airHangLineInfluence); ySmoothTime = Mathf.Max(0.001f, ySmoothTime); maxYSpeed = Mathf.Max(0.01f, maxYSpeed); xzSmoothTime = Mathf.Max(0.001f, xzSmoothTime); driftForceDuration = Mathf.Max(0.02f, driftForceDuration); waterConstraintMaxCorrection = Mathf.Max(0f, waterConstraintMaxCorrection); rotationLerpSpeed = Mathf.Max(0.01f, rotationLerpSpeed); maxPlanarOffset = Mathf.Max(0f, maxPlanarOffset); waterConstraintPlanarStrength = Mathf.Clamp01(waterConstraintPlanarStrength); waterConstraintUpwardStrength = Mathf.Clamp01(waterConstraintUpwardStrength); waterConstraintDownwardStrength = Mathf.Clamp01(waterConstraintDownwardStrength); downForceToSink = Mathf.Max(0f, downForceToSink); maxExtraSink = Mathf.Max(0f, maxExtraSink); surfaceBobAmplitude = Mathf.Max(0f, surfaceBobAmplitude); surfaceBobFrequency = Mathf.Max(0f, surfaceBobFrequency); surfaceBobSecondaryRatio = Mathf.Clamp01(surfaceBobSecondaryRatio); yDeadZone = Mathf.Max(0f, yDeadZone); if (airHangDownAxisLocal.sqrMagnitude < 1e-6f) airHangDownAxisLocal = Vector3.down; if (airHangForwardAxisLocal.sqrMagnitude < 1e-6f) airHangForwardAxisLocal = Vector3.forward; 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); postureBlendSmoothTime = Mathf.Max(0.001f, postureBlendSmoothTime); postureBlendSmoothTimeUpright = Mathf.Max(0.001f, postureBlendSmoothTimeUpright); postureWobbleStrength = Mathf.Max(0f, postureWobbleStrength); postureWobbleDecay = Mathf.Max(0.01f, postureWobbleDecay); entryDipAmplitude = Mathf.Max(0f, entryDipAmplitude); entrySettleDuration = Mathf.Max(0f, entrySettleDuration); } #endif #endregion } }