using UnityEngine; /// /// 简单水面接口。你可以替换成自己的水系统。 /// 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, // 轻点 SlowSink, // 缓沉 Lift, // 送漂 BlackDrift // 黑漂/快速拖入 } public enum BobberPosture { Lying, Tilted, Upright } [DisallowMultipleComponent] [RequireComponent(typeof(Rigidbody))] public class BobberPresentationController : MonoBehaviour { [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("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("水面轻微起伏频率")] 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 Rotation")] [Tooltip("倾斜状态角度")] public float tiltedAngle = 38f; [Tooltip("躺漂角度")] public float lyingAngle = 88f; [Tooltip("立漂时允许的最大附加倾角")] public float uprightMaxTiltAngle = 8f; [Tooltip("平面方向对立漂/斜漂附加倾角的影响强度")] public float planarTiltFactor = 120f; [Tooltip("姿态平滑速度")] public float rotationLerpSpeed = 8f; [Header("Debug Input")] [Tooltip("调试:按 R 恢复默认")] public bool debugResetKey = true; [Tooltip("调试:按 T 触发轻点")] public bool debugTapKey = true; [Tooltip("调试:按 G 触发缓沉")] public bool debugSlowSinkKey = true; [Tooltip("调试:按 H 触发送漂")] public bool debugLiftKey = true; [Tooltip("调试:按 B 触发黑漂")] public bool debugBlackDriftKey = true; [Header("Debug")] public bool drawDebug = false; 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 Vector3 _waterAnchorPos; private Vector3 _xzSmoothVelocity; private float _ySmoothVelocity; private float _biteOffsetY; private float _biteOffsetYVelocity; private Quaternion _targetRotation; // bite event runtime private BobberBiteType _activeBiteType = BobberBiteType.None; private float _biteTimer; private float _biteDuration; private float _biteAmplitude; private Vector3 _blackDriftDirection; // posture runtime private float _verticalRatio; private float _planarRatio; private float _verticalDistance; private float _planarDistance; private void Awake() { _rb = GetComponent(); _defaultLinearDamping = _rb.linearDamping; _defaultAngularDamping = _rb.angularDamping; _defaultUseGravity = _rb.useGravity; if (waterProviderBehaviour != null) _waterProvider = waterProviderBehaviour as IWaterSurfaceProvider; _targetRotation = transform.rotation; } 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(submergeDepth); break; case BobberControlMode.WaterPresentation: UpdateWaterPresentation(waterY, submergeDepth); break; } if (drawDebug) { DrawDebug(waterY); } } #region Main Update private void UpdateAirPhysics(float submergeDepth) { RestoreAirPhysicsState(); if (submergeDepth > enterWaterDepth) { EnterWaterPresentationMode(); } } private void UpdateWaterPresentation(float waterY, float submergeDepth) { if (submergeDepth < exitWaterDepth) { ExitWaterPresentationMode(); return; } // 完全关闭刚体干扰 _rb.useGravity = false; _rb.linearVelocity = Vector3.zero; _rb.angularVelocity = Vector3.zero; _rb.linearDamping = 999f; _rb.angularDamping = 999f; UpdateBiteAnimation(); 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( current: pos.y, target: targetY, currentVelocity: ref _ySmoothVelocity, smoothTime: Mathf.Max(0.0001f, ySmoothTime), maxSpeed: maxYSpeed, deltaTime: 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; // 3. 姿态判定 + 目标旋转 EvaluatePostureByComponents(waterY); UpdateTargetRotationByPosture(); transform.rotation = Quaternion.Slerp( transform.rotation, _targetRotation, 1f - Mathf.Exp(-rotationLerpSpeed * Time.fixedDeltaTime) ); } #endregion #region Mode Switch private void EnterWaterPresentationMode() { _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; _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() { _rb.useGravity = _defaultUseGravity; _rb.linearDamping = _defaultLinearDamping; _rb.angularDamping = _defaultAngularDamping; } #endregion #region Target Calculation private float CalculateTargetY(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) { surfaceBob = Mathf.Sin(Time.time * surfaceBobFrequency * Mathf.PI * 2f) * surfaceBobAmplitude; } 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 ? _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 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 submergeRatio = Mathf.Clamp01( (waterY - GetBottomWorldPosition().y) / Mathf.Max(0.0001f, floatHeight) ); if (lureBody == null) { _verticalDistance = 0f; _planarDistance = 0f; _verticalRatio = 0f; _planarRatio = 0f; if (submergeRatio < minSubmergeToStand) _posture = BobberPosture.Lying; else if (ExternalPlanarOffset.magnitude > 0.01f) _posture = BobberPosture.Tilted; else _posture = BobberPosture.Upright; return; } 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; 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) { _posture = BobberPosture.Upright; } else if (canTilt) { _posture = BobberPosture.Tilted; } break; } 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) { _posture = BobberPosture.Lying; } else if (shouldStand) { _posture = BobberPosture.Upright; } break; } case BobberPosture.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) { _posture = BobberPosture.Lying; } else if (shouldTilt) { _posture = BobberPosture.Tilted; } break; } } } private void UpdateTargetRotationByPosture() { Vector3 planarDir = Vector3.zero; if (lureBody != null) { Vector3 delta = lureBody.worldCenterOfMass - _rb.worldCenterOfMass; planarDir = Vector3.ProjectOnPlane(delta, Vector3.up); } if (planarDir.sqrMagnitude < 1e-6f) { planarDir = new Vector3(_xzSmoothVelocity.x, 0f, _xzSmoothVelocity.z); } if (planarDir.sqrMagnitude < 1e-6f) { planarDir = new Vector3(ExternalPlanarOffset.x, 0f, ExternalPlanarOffset.y); } if (planarDir.sqrMagnitude < 1e-6f) { planarDir = transform.forward; } planarDir.Normalize(); Vector3 tiltAxis = Vector3.Cross(Vector3.up, planarDir); if (tiltAxis.sqrMagnitude < 1e-6f) { tiltAxis = transform.right; } float angle; switch (_posture) { case BobberPosture.Lying: angle = lyingAngle; break; case BobberPosture.Tilted: { float extra = Mathf.Clamp(_planarRatio * planarTiltFactor, 0f, 18f); angle = Mathf.Clamp(tiltedAngle + extra, tiltedAngle, lyingAngle); break; } default: { float extra = Mathf.Clamp(_planarRatio * planarTiltFactor, 0f, uprightMaxTiltAngle); angle = extra; break; } } _targetRotation = Quaternion.AngleAxis(angle, tiltAxis.normalized); } #endregion #region Bite Presentation /// /// 轻点:快速下顿再回弹 /// 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 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() { 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: if (t < 0.35f) { float k = t / 0.35f; targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, k); } else { float 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, Time.fixedDeltaTime ); if (_biteTimer >= _biteDuration) { if (_activeBiteType == BobberBiteType.SlowSink || _activeBiteType == BobberBiteType.BlackDrift) { return; } _activeBiteType = BobberBiteType.None; } } #endregion #region Utilities private float GetWaterHeight(Vector3 worldPos) { return _waterProvider != null ? _waterProvider.GetWaterHeight(worldPos) : 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) { 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.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); } 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); 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); tiltedAngle = Mathf.Clamp(tiltedAngle, 0f, 89f); lyingAngle = Mathf.Clamp(lyingAngle, tiltedAngle, 89.9f); uprightMaxTiltAngle = Mathf.Clamp(uprightMaxTiltAngle, 0f, tiltedAngle); planarTiltFactor = Mathf.Max(0f, planarTiltFactor); } #endif #endregion }