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
}