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
}
}