using UnityEngine; namespace NBF { public enum FishingBobberControlMode { AirPhysics, WaterPresentation, } public enum FishingBobberBiteType { None, Tap, SlowSink, Lift, BlackDrift, } public enum BobberTiltAxis { LocalX, LocalZ, } [DisallowMultipleComponent] [RequireComponent(typeof(Rigidbody))] public class FishingBobberFeature : FishingLineNodeMotionFeature { protected override int DefaultPriority => 100; #region 检测入水 [Header("入水检测")] [Tooltip("当前测试阶段固定水面高度。")] public float waterLevel = 0f; [Tooltip("浮漂底部进入水面达到该深度后,切换为水面表现控制。")] public float enterWaterDepth = 0.002f; [Tooltip("浮漂底部高于该深度时退出水面表现控制。通常设为负值用于滞回。")] public float exitWaterDepth = -0.01f; [Tooltip("浮漂总高度,单位米。")] public float floatHeight = 0.08f; [Tooltip("如果 Pivot 在浮漂底部填 0;如果 Pivot 在模型中部,填底部相对 Pivot 的本地 Y 偏移。")] public float bottomOffsetLocalY; [Tooltip("Y 轴控制的平滑时间。")] public float ySmoothTime = 0.08f; [Tooltip("Y 轴平滑时允许的最大竖直速度。")] public float maxYSpeed = 2f; [Tooltip("Y 轴死区,小范围内直接贴目标值以减少微抖。")] public float yDeadZone = 0.0005f; #endregion #region 吃水控制 [Header("吃水控制")] [Tooltip("基准底部重量。当前重量等于该值时,使用基础吃水深度。")] public float neutralBottomWeight = 1f; [Tooltip("当前底部总重量。运行时可由其他脚本更新。")] public float currentBottomWeight = 1f; [Tooltip("基准重量下的基础吃水深度。")] public float baseDraftDepth = 0.02f; [Tooltip("每单位重量变化对应增加或减少的吃水深度。")] public float draftDepthPerWeight = 0.01f; [Tooltip("吃水深度下限。")] public float minDraftDepth = 0.005f; [Tooltip("吃水深度上限。")] public float maxDraftDepth = 0.08f; [Tooltip("吃水深度变化的平滑时间。")] public float draftSmoothTime = 0.18f; #endregion #region 漂相动画 [Header("漂相动画")] [Tooltip("漂相位移动画的平滑时间。")] public float biteSmoothTime = 0.03f; [Tooltip("点漂默认振幅。")] public float tapAmplitude = 0.008f; [Tooltip("点漂默认时长。")] public float tapDuration = 0.18f; [Tooltip("缓沉默认振幅。")] public float slowSinkAmplitude = 0.025f; [Tooltip("缓沉默认时长。")] public float slowSinkDuration = 1.2f; [Tooltip("顶漂默认振幅。")] public float liftAmplitude = 0.015f; [Tooltip("顶漂默认时长。")] public float liftDuration = 1.2f; [Tooltip("黑漂默认振幅。")] public float blackDriftAmplitude = 0.06f; [Tooltip("黑漂默认时长。")] public float blackDriftDuration = 0.8f; #endregion #region 输入测试 [Header("输入测试")] [Tooltip("是否启用运行时按键测试漂相。")] public bool enableDebugInput = true; [Tooltip("停止当前漂相的按键。")] public KeyCode stopBiteKey = KeyCode.R; [Tooltip("触发点漂的按键。")] public KeyCode tapKey = KeyCode.T; [Tooltip("触发缓沉的按键。")] public KeyCode slowSinkKey = KeyCode.G; [Tooltip("触发顶漂的按键。")] public KeyCode liftKey = KeyCode.H; [Tooltip("触发黑漂的按键。")] public KeyCode blackDriftKey = KeyCode.B; #endregion #region 姿态控制 [Header("姿态控制")] [Tooltip("重量低于该值时,姿态趋向躺漂。")] public float lyingWeightThreshold = 0.4f; [Tooltip("重量达到该值附近时,姿态趋向半躺。")] public float tiltedWeightThreshold = 0.8f; [Tooltip("重量达到该值及以上时,姿态趋向立漂。")] public float uprightWeightThreshold = 1.2f; [Tooltip("躺漂对应的倾角。")] public float lyingAngle = 88f; [Tooltip("半躺对应的倾角。")] public float tiltedAngle = 42f; [Tooltip("立漂对应的倾角,通常为 0。")] public float uprightAngle = 0f; [Tooltip("绕哪个本地轴做倾倒。")] public BobberTiltAxis tiltAxis = BobberTiltAxis.LocalX; [Tooltip("是否反转倾倒方向。")] public bool invertTiltDirection; [Tooltip("姿态旋转的平滑速度。")] public float rotationLerpSpeed = 8f; [Tooltip("入水后用于压制旋转抖动的角阻尼。")] public float waterAngularDamping = 999f; #endregion #region 运行时状态 public FishingBobberControlMode CurrentMode => _mode; public FishingBobberBiteType CurrentBiteType => _activeBiteType; public float CurrentDraftDepth => _currentDraftDepth; public float CurrentBottomWeight => currentBottomWeight; private Rigidbody _rb; private FishingBobberControlMode _mode = FishingBobberControlMode.AirPhysics; private bool _defaultsCached; private bool _waterStateInitialized; private float _defaultAngularDamping; private bool _defaultUseGravity; private RigidbodyConstraints _defaultConstraints; private float _draftVelocity; private float _currentDraftDepth; private float _ySmoothVelocity; private float _biteOffsetY; private float _biteOffsetYVelocity; private bool _uprightPoseCached; private Quaternion _cachedUprightRotation; private Quaternion _uprightReferenceRotation; private Quaternion _targetRotation; private FishingBobberBiteType _activeBiteType = FishingBobberBiteType.None; private float _biteTimer; private float _biteDuration; private float _biteAmplitude; #endregion #region Unity 生命周期 private void Awake() { EnsureRuntimeReferences(); InitializeRuntimeState(); } private void Update() { HandleDebugInput(); } #endregion #region 运动控制接入 public override bool IsSupportedNode(FishingLineNode node) { return node != null && node.Type == FishingLineNode.NodeType.Float; } protected override void OnBind() { EnsureRuntimeReferences(); InitializeRuntimeState(); } public override bool CanControl() { EnsureRuntimeReferences(); if (_rb == null || !IsSupportedNode(Node)) { return false; } var submergeDepth = GetSubmergeDepth(); if (_mode == FishingBobberControlMode.WaterPresentation) { return submergeDepth >= exitWaterDepth; } return submergeDepth > enterWaterDepth; } public override void OnMotionActivated() { EnsureRuntimeReferences(); EnterWaterPresentationMode(); } public override void OnMotionDeactivated() { EnsureRuntimeReferences(); ExitWaterPresentationMode(); } public override void TickMotion(float deltaTime) { EnsureRuntimeReferences(); if (_rb == null) { return; } var submergeDepth = GetSubmergeDepth(); if (submergeDepth < exitWaterDepth) { ExitWaterPresentationMode(); return; } if (_mode != FishingBobberControlMode.WaterPresentation) { EnterWaterPresentationMode(); } UpdateBiteAnimation(deltaTime); UpdateDraft(deltaTime); var nextRotation = CalculateNextRotation(deltaTime); UpdateVerticalPosition(deltaTime, nextRotation); ApplyRotation(nextRotation); } #endregion #region 漂相接口 public void SetBottomWeight(float weight) { currentBottomWeight = weight; } public void PlayTap(float amplitude = -1f, float duration = -1f) { StartBite( FishingBobberBiteType.Tap, amplitude > 0f ? amplitude : tapAmplitude, duration > 0f ? duration : tapDuration); } public void PlaySlowSink(float amplitude = -1f, float duration = -1f) { StartBite( FishingBobberBiteType.SlowSink, amplitude > 0f ? amplitude : slowSinkAmplitude, duration > 0f ? duration : slowSinkDuration); } public void PlayLift(float amplitude = -1f, float duration = -1f) { StartBite( FishingBobberBiteType.Lift, amplitude > 0f ? amplitude : liftAmplitude, duration > 0f ? duration : liftDuration); } public void PlayBlackDrift(float amplitude = -1f, float duration = -1f) { StartBite( FishingBobberBiteType.BlackDrift, amplitude > 0f ? amplitude : blackDriftAmplitude, duration > 0f ? duration : blackDriftDuration); } public void StopBite() { _activeBiteType = FishingBobberBiteType.None; _biteTimer = 0f; _biteDuration = 0f; _biteAmplitude = 0f; } #endregion #region 内部实现 private void EnsureRuntimeReferences() { if (_rb == null) { _rb = Node != null && Node.Body != null ? Node.Body : GetComponent(); } } private void InitializeRuntimeState() { if (_rb == null) { return; } if (!_defaultsCached) { _defaultAngularDamping = _rb.angularDamping; _defaultUseGravity = _rb.useGravity; _defaultConstraints = _rb.constraints; _defaultsCached = true; } _currentDraftDepth = CalculateRawDraftDepth(); _draftVelocity = 0f; _ySmoothVelocity = 0f; _biteOffsetY = 0f; _biteOffsetYVelocity = 0f; _targetRotation = _rb.rotation; if (!_uprightPoseCached) { _cachedUprightRotation = _rb.rotation; _uprightPoseCached = true; } _uprightReferenceRotation = _cachedUprightRotation; } private void EnterWaterPresentationMode() { if (_rb == null) { return; } _mode = FishingBobberControlMode.WaterPresentation; _waterStateInitialized = true; _uprightReferenceRotation = _cachedUprightRotation; _targetRotation = _rb.rotation; _draftVelocity = 0f; _ySmoothVelocity = 0f; _biteOffsetYVelocity = 0f; _currentDraftDepth = CalculateRawDraftDepth(); _rb.useGravity = false; _rb.angularDamping = waterAngularDamping; _rb.constraints = _defaultConstraints | RigidbodyConstraints.FreezeRotation; _rb.angularVelocity = Vector3.zero; } private void ExitWaterPresentationMode() { _mode = FishingBobberControlMode.AirPhysics; RestorePhysicsState(); } private void RestorePhysicsState() { if (_rb == null || !_defaultsCached) { return; } _rb.useGravity = _defaultUseGravity; _rb.angularDamping = _defaultAngularDamping; _rb.constraints = _defaultConstraints; } private float GetSubmergeDepth() { return waterLevel - GetBottomWorldPosition().y; } private Vector3 GetBottomWorldPosition() { return transform.TransformPoint(new Vector3(0f, bottomOffsetLocalY, 0f)); } private float CalculateRawDraftDepth() { var weightDelta = currentBottomWeight - neutralBottomWeight; var targetDraft = baseDraftDepth + weightDelta * draftDepthPerWeight; return Mathf.Clamp(targetDraft, minDraftDepth, maxDraftDepth); } private void UpdateDraft(float deltaTime) { var targetDraft = CalculateRawDraftDepth(); _currentDraftDepth = Mathf.SmoothDamp( _currentDraftDepth, targetDraft, ref _draftVelocity, Mathf.Max(0.0001f, draftSmoothTime), Mathf.Infinity, deltaTime); } private void UpdateVerticalPosition(float deltaTime, Quaternion targetRotation) { var position = _rb.position; var targetY = waterLevel - _currentDraftDepth - GetBottomOffsetWorldY(targetRotation) + _biteOffsetY; if (Mathf.Abs(position.y - targetY) < yDeadZone) { position.y = targetY; _ySmoothVelocity = 0f; } else { position.y = Mathf.SmoothDamp( position.y, targetY, ref _ySmoothVelocity, Mathf.Max(0.0001f, ySmoothTime), maxYSpeed, deltaTime); } _rb.MovePosition(position); var velocity = _rb.linearVelocity; if (Mathf.Abs(velocity.y) > 0f) { velocity.y = 0f; _rb.linearVelocity = velocity; } } private Quaternion CalculateNextRotation(float deltaTime) { var targetTiltAngle = EvaluateTargetTiltAngle(); var signedAngle = invertTiltDirection ? -targetTiltAngle : targetTiltAngle; var localAxis = tiltAxis == BobberTiltAxis.LocalX ? Vector3.right : Vector3.forward; _targetRotation = _uprightReferenceRotation * Quaternion.AngleAxis(signedAngle, localAxis); _rb.angularVelocity = Vector3.zero; return Quaternion.Slerp( _rb.rotation, _targetRotation, 1f - Mathf.Exp(-Mathf.Max(0.01f, rotationLerpSpeed) * deltaTime)); } private void ApplyRotation(Quaternion nextRotation) { _rb.rotation = nextRotation; } private float GetBottomOffsetWorldY(Quaternion rotation) { return (rotation * new Vector3(0f, bottomOffsetLocalY, 0f)).y; } private float EvaluateTargetTiltAngle() { if (currentBottomWeight <= lyingWeightThreshold) { return lyingAngle; } if (currentBottomWeight <= tiltedWeightThreshold) { var t = Mathf.InverseLerp(lyingWeightThreshold, tiltedWeightThreshold, currentBottomWeight); return Mathf.Lerp(lyingAngle, tiltedAngle, t); } if (currentBottomWeight <= uprightWeightThreshold) { var t = Mathf.InverseLerp(tiltedWeightThreshold, uprightWeightThreshold, currentBottomWeight); return Mathf.Lerp(tiltedAngle, uprightAngle, t); } return uprightAngle; } private void StartBite(FishingBobberBiteType type, float amplitude, float duration) { if (_mode != FishingBobberControlMode.WaterPresentation) { return; } _activeBiteType = type; _biteTimer = 0f; _biteDuration = Mathf.Max(0.01f, duration); _biteAmplitude = Mathf.Max(0f, amplitude); _biteOffsetYVelocity = 0f; } private void UpdateBiteAnimation(float deltaTime) { if (_activeBiteType == FishingBobberBiteType.None) { _biteOffsetY = Mathf.SmoothDamp( _biteOffsetY, 0f, ref _biteOffsetYVelocity, Mathf.Max(0.0001f, biteSmoothTime), Mathf.Infinity, deltaTime); return; } _biteTimer += deltaTime; var t = Mathf.Clamp01(_biteTimer / _biteDuration); var targetOffset = 0f; switch (_activeBiteType) { case FishingBobberBiteType.Tap: if (t < 0.35f) { var downT = t / 0.35f; targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, downT); } else { var upT = (t - 0.35f) / 0.65f; targetOffset = -Mathf.Lerp(_biteAmplitude, 0f, upT); } break; case FishingBobberBiteType.SlowSink: targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, t); break; case FishingBobberBiteType.Lift: targetOffset = Mathf.SmoothStep(0f, _biteAmplitude, t); break; case FishingBobberBiteType.BlackDrift: targetOffset = -Mathf.SmoothStep(0f, _biteAmplitude, t); break; } _biteOffsetY = Mathf.SmoothDamp( _biteOffsetY, targetOffset, ref _biteOffsetYVelocity, Mathf.Max(0.0001f, biteSmoothTime), Mathf.Infinity, deltaTime); if (_biteTimer >= _biteDuration && _activeBiteType != FishingBobberBiteType.SlowSink && _activeBiteType != FishingBobberBiteType.BlackDrift) { _activeBiteType = FishingBobberBiteType.None; } } private void HandleDebugInput() { if (!Application.isPlaying || !enableDebugInput) { return; } if (Input.GetKeyDown(stopBiteKey)) { StopBite(); } if (Input.GetKeyDown(tapKey)) { PlayTap(); } if (Input.GetKeyDown(slowSinkKey)) { PlaySlowSink(); } if (Input.GetKeyDown(liftKey)) { PlayLift(); } if (Input.GetKeyDown(blackDriftKey)) { PlayBlackDrift(); } } #endregion #region 参数校验 #if UNITY_EDITOR private void OnValidate() { floatHeight = Mathf.Max(0.001f, floatHeight); ySmoothTime = Mathf.Max(0.001f, ySmoothTime); maxYSpeed = Mathf.Max(0.01f, maxYSpeed); yDeadZone = Mathf.Max(0f, yDeadZone); neutralBottomWeight = Mathf.Max(0f, neutralBottomWeight); currentBottomWeight = Mathf.Max(0f, currentBottomWeight); minDraftDepth = Mathf.Max(0f, minDraftDepth); maxDraftDepth = Mathf.Max(minDraftDepth, maxDraftDepth); baseDraftDepth = Mathf.Clamp(baseDraftDepth, minDraftDepth, maxDraftDepth); draftDepthPerWeight = Mathf.Max(0f, draftDepthPerWeight); draftSmoothTime = Mathf.Max(0.001f, draftSmoothTime); biteSmoothTime = Mathf.Max(0.001f, biteSmoothTime); tapAmplitude = Mathf.Max(0f, tapAmplitude); tapDuration = Mathf.Max(0.01f, tapDuration); slowSinkAmplitude = Mathf.Max(0f, slowSinkAmplitude); slowSinkDuration = Mathf.Max(0.01f, slowSinkDuration); liftAmplitude = Mathf.Max(0f, liftAmplitude); liftDuration = Mathf.Max(0.01f, liftDuration); blackDriftAmplitude = Mathf.Max(0f, blackDriftAmplitude); blackDriftDuration = Mathf.Max(0.01f, blackDriftDuration); lyingWeightThreshold = Mathf.Max(0f, lyingWeightThreshold); tiltedWeightThreshold = Mathf.Max(lyingWeightThreshold, tiltedWeightThreshold); uprightWeightThreshold = Mathf.Max(tiltedWeightThreshold, uprightWeightThreshold); uprightAngle = Mathf.Clamp(uprightAngle, 0f, 89f); tiltedAngle = Mathf.Clamp(tiltedAngle, uprightAngle, 89f); lyingAngle = Mathf.Clamp(lyingAngle, tiltedAngle, 89.9f); rotationLerpSpeed = Mathf.Max(0.01f, rotationLerpSpeed); waterAngularDamping = Mathf.Max(0f, waterAngularDamping); } #endif #endregion } }