Files
Fishing2/Assets/Scripts/Fishing/New/View/FishingLine/Provider/FLineNodeBobberProvider.cs
Bob.Song d432c468b1 接入新逻辑
# Conflicts:
#	Assets/Scenes/RopeTest.unity
#	Assets/Scripts/Fishing/New/View/Player/Tackle/FLine.cs
#	Assets/Scripts/Fishing/Rope/Rope.cs
#	Assets/Scripts/Fishing/Rope/Rope.cs.meta
2026-04-26 14:44:06 +08:00

1539 lines
62 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
namespace NBF
{
/// <summary>
/// 简单水面接口。你可以替换成自己的水系统。
/// </summary>
public interface IWaterSurfaceProvider
{
float GetWaterHeight(Vector3 worldPos);
Vector3 GetWaterNormal(Vector3 worldPos);
}
/// <summary>
/// 浮漂控制模式:
/// 1. AirPhysics空中/未入水,使用刚体物理
/// 2. WaterPresentation入水后关闭重力Y 和旋转由脚本控制
/// </summary>
public enum BobberControlMode
{
AirPhysics,
WaterPresentation
}
/// <summary>
/// 漂像事件类型
/// </summary>
public enum BobberBiteType
{
None,
Tap, // 轻点:快速下顿再回弹
MultiTap, // 连顿:连续多次下顿(鱼反复试探)
SlowSink, // 缓沉:分段下沉,中途短暂停顿
Lift, // 送漂:上抬至高位后轻微晃动稳住
BlackDrift, // 黑漂:快速深沉并水平拖拽
Tremble // 微颤:高频小幅抖动(鱼在蹭饵试探)
}
public enum BobberPosture
{
Lying, // 躺漂
Tilted, // 斜漂
Upright // 立漂
}
/// <summary>
/// 入水后的内部阶段,可供外部查询或驱动 UI/音效
/// </summary>
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;
/// <summary>连续姿态混合值0=躺漂, 1=斜漂, 2=立漂。可直接驱动外部动画混合树。</summary>
public float PostureBlendValue => _postureBlendValue;
// ── 外部可写输入 ───────────────────────────────────────────────
/// <summary>等效向下拉力(不必须是真实力,作为输入信号即可)</summary>
public float ExternalDownForce { get; set; }
/// <summary>是否触底</summary>
public bool IsBottomTouched { get; set; }
/// <summary>额外平面偏移(例如风、水流、拖拽)</summary>
public Vector2 ExternalPlanarOffset { get; set; }
// ── 事件 ──────────────────────────────────────────────────────
/// <summary>姿态切换完成时触发:(旧姿态, 新姿态)</summary>
public event System.Action<BobberPosture, BobberPosture> 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<Rigidbody>();
_logicNode = GetComponent<FLineLogicNode>();
_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);
}
/// <summary>
/// 姿态状态判断的唯一入口。改规则只改这里,外部负责数据采样和旋转平滑。
/// </summary>
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);
}
}
/// <summary>
/// 姿态切换完成时:触发外部事件,并向弹簧注入回弹初速。
/// </summary>
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);
}
/// <summary>
/// 临界阻尼弹簧k = decay², damper = 2*decay每帧调用以衰减旋转抖动。
/// </summary>
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
/// <summary>轻点:快速下顿再回弹</summary>
public void PlayTap(float amplitude = 0.008f, float duration = 0.18f)
{
StartBite(BobberBiteType.Tap, amplitude, duration);
}
/// <summary>连顿count 次连续下顿,每顿之间轻微回弹,振幅略微衰减</summary>
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);
}
/// <summary>缓沉分段下沉40% 处有短暂停顿感(鱼吞饵试探)</summary>
public void PlaySlowSink(float amplitude = 0.025f, float duration = 1.2f)
{
StartBite(BobberBiteType.SlowSink, amplitude, duration);
}
/// <summary>送漂:快速上抬至峰值,之后轻微振荡稳住</summary>
public void PlayLift(float amplitude = 0.015f, float duration = 1.2f)
{
StartBite(BobberBiteType.Lift, amplitude, duration);
}
/// <summary>黑漂:快速深沉并可配合平面拖拽</summary>
public void PlayBlackDrift(float amplitude = 0.06f, float duration = 0.8f, Vector3? driftDirection = null)
{
StartBite(BobberBiteType.BlackDrift, amplitude, duration);
_blackDriftDirection = (driftDirection ?? transform.forward).normalized;
}
/// <summary>微颤:高频小幅抖动,振幅随时间衰减(鱼蹭饵试探)</summary>
public void PlayTremble(float amplitude = 0.004f, float duration = 1.5f, float frequency = 10f)
{
_trembleRuntimeFreq = Mathf.Max(1f, frequency);
StartBite(BobberBiteType.Tremble, amplitude, duration);
}
/// <summary>移漂:按给定世界方向在 XZ 平面平移指定距离。</summary>
public void PlayDrift(Vector3 direction, float distance)
{
PlayDrift(direction, distance, driftForceDuration);
}
/// <summary>移漂:沿给定方向在 XZ 平面移动指定距离,并在目标点停下。</summary>
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);
// 姿态混合值可视化:白线高度代表 PostureBlendValue0~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
}
}