Files
WhaleAdventure_VR/Assets/My project/Fishing Scripts/UI/FishingRodVRController.cs
2026-06-26 18:05:00 +09:00

1538 lines
53 KiB
C#

using System;
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
/// <summary>
/// Integrated VR fishing rod controller.
///
/// Replaces the following helper scripts when you want a simpler VR fishing setup:
/// - FishingModelLineFollower
/// - FishingBobberVRController
/// - FishingBobberWaterDetector
/// - FishingRodState
/// - FishingStartTrigger
///
/// Keep FishingGameManager, FishingGaugeUI, FishingRewardSystem, FishingHapticManager,
/// FishingUIEffects, FishingItemType, and RotateUI as separate scripts.
///
/// Recommended hierarchy:
/// Fishing rod
/// - Rigidbody
/// - XR Grab Interactable
/// - FishingRodVRController
/// - RodLineEndPoint
/// - BobberLineStartPoint
/// - Bobber / Hook_2
/// - FishingLineRoot (optional empty object for generated line mesh)
/// </summary>
public class FishingRodVRController : MonoBehaviour
{
public enum BobberState
{
Idle,
Casting,
WaitingInWater,
Bite,
MiniGameActive,
Returning
}
public enum StartMode
{
Disabled,
StartActionInArea,
OnBite,
ManualOnly
}
[Header("Core References")]
[SerializeField] private FishingGameManager fishingGameManager;
[SerializeField] private FishingHapticManager haptic;
[Tooltip("Line start point. Place this at the fishing rod tip / final guide ring.")]
[SerializeField] private Transform rodLineEndPoint;
[Tooltip("Line end point. The visible bobber should usually be a child of this object.")]
[SerializeField] private Transform bobberLineStartPoint;
[Tooltip("Visible bobber or hook model. Usually a child of BobberLineStartPoint. Used for optional visual correction/shake.")]
[SerializeField] private Transform bobberVisualRoot;
[Header("Rod Held State")]
[Tooltip("Call MarkHeld/MarkReleased from XR Grab Interactable Select Entered/Exited events.")]
[SerializeField] private bool isHeld;
[SerializeField] private bool showHeldDebugLog = true;
[Header("Input Actions")]
[Tooltip("Used to cast the bobber forward.")]
[SerializeField] private InputActionReference castAction;
[Tooltip("Used to return the bobber to the rod tip.")]
[SerializeField] private InputActionReference returnAction;
[Tooltip("Used to start fishing while player is in the start area, if Start Mode = StartActionInArea.")]
[SerializeField] private InputActionReference startAction;
[Tooltip("If the action is not already enabled by Input Action Manager, this script enables it temporarily.")]
[SerializeField] private bool enableInputActionsManually = true;
[Header("Start Area / Start Rules")]
[SerializeField] private StartMode startMode = StartMode.OnBite;
[Tooltip("If On, StartActionInArea mode requires the rod to be held.")]
[SerializeField] private bool requireRodHeldToStart = true;
[Tooltip("If On, CastForward requires the rod to be held.")]
[SerializeField] private bool requireRodHeldToCast = true;
[Tooltip("Shown while player is inside the start area and the rules allow starting.")]
[SerializeField] private GameObject startGuideUI;
[Tooltip("If On, an OverlapBox is used instead of a separate FishingStartTrigger component.")]
[SerializeField] private bool useStartAreaCheck = false;
[SerializeField] private Transform startAreaCenter;
[SerializeField] private Vector3 startAreaHalfExtents = new Vector3(1.5f, 1.2f, 1.5f);
[SerializeField] private bool startAreaUsePlayerLayerMask = false;
[SerializeField] private LayerMask startAreaPlayerLayerMask = ~0;
[SerializeField] private string playerTag = "Player";
[SerializeField] private bool hideGuideWhenFishingStarts = true;
[SerializeField] private bool allowRestartAfterClear = false;
[Header("Procedural Fishing Line")]
[Tooltip("Optional transform that owns the generated MeshFilter/MeshRenderer. If empty, this object is used.")]
[SerializeField] private Transform lineMeshRoot;
[SerializeField] private Material lineMaterial;
[SerializeField] private float lineRadius = 0.0012f;
[SerializeField] private int lineSegments = 12;
[SerializeField] private int lineSides = 6;
[SerializeField] private bool lineUseSag = false;
[SerializeField] private float lineSagAmount = 0.02f;
[SerializeField] private float lineDistanceSagMultiplier = 0.01f;
[SerializeField] private float lineMaxSagAmount = 0.08f;
[SerializeField] private Vector3 lineSagDirection = Vector3.down;
[SerializeField] private bool lineAddEndCaps = false;
[SerializeField] private bool hideLineWhenMissingTargets = true;
[SerializeField] private bool drawLineDebug = false;
[Header("Bobber Idle / Attachment")]
[Tooltip("If set, ReturnBobber uses this world position. If empty, initial BobberLineStartPoint local position is used.")]
[SerializeField] private Transform bobberRestPoint;
[Tooltip("On Start, put BobberLineStartPoint at its rest position.")]
[SerializeField] private bool returnBobberToRestOnStart = true;
[Tooltip("If On, BobberLineStartPoint is kept at the cast world position after casting, even while the rod moves.")]
[SerializeField] private bool pinBobberWorldPositionAfterCast = true;
[Tooltip("If the visible bobber is not a child of BobberLineStartPoint, this can force it to the line end.")]
[SerializeField] private bool forceBobberVisualToLineEnd = false;
[SerializeField] private Vector3 bobberVisualLocalOffset = Vector3.zero;
[SerializeField] private Vector3 bobberVisualWorldOffset = Vector3.zero;
[Header("Casting")]
[SerializeField] private Transform castDirectionSource;
[SerializeField] private float castDistance = 4f;
[SerializeField] private float castHeight = 1.1f;
[SerializeField] private float castDuration = 0.65f;
[SerializeField] private bool flattenCastDirection = true;
[Tooltip("If On, raycast down to find water hit point for the cast target.")]
[SerializeField] private bool useWaterRaycastTarget = true;
[SerializeField] private LayerMask waterRaycastMask = 0;
[SerializeField] private float waterRaycastStartUp = 2f;
[SerializeField] private float waterRaycastDownDistance = 8f;
[SerializeField] private bool useFixedWaterHeightFallback = false;
[SerializeField] private float fixedWaterHeight = 0f;
[SerializeField] private float bobberFloatHeightOffset = 0.02f;
[Header("Water Detection")]
[Tooltip("If On, this script checks water with OverlapSphere around BobberLineStartPoint. No BobberWaterDetector is needed.")]
[SerializeField] private bool useWaterOverlapCheck = true;
[SerializeField] private float waterCheckRadius = 0.08f;
[SerializeField] private bool useWaterLayerMask = true;
[SerializeField] private LayerMask waterLayerMask = 0;
[SerializeField] private bool useWaterTag = true;
[SerializeField] private string waterTag = "Water";
[Header("Bite")]
[SerializeField] private bool useBiteTimer = true;
[SerializeField] private Vector2 biteDelayRange = new Vector2(1.5f, 4.0f);
[SerializeField] private bool startMiniGameOnBite = true;
[SerializeField] private bool stopBiteShakeWhenMiniGameStarts = true;
[SerializeField] private float biteShakeAmplitude = 0.035f;
[SerializeField] private float biteShakeFrequency = 18f;
[SerializeField] private float biteShakeDuration = 1.25f;
[SerializeField] private bool keepShakingUntilMiniGameStarts = false;
[Header("Reel Handle / Line Length Control")]
[Tooltip("If On, rotating a hand/controller around the reel center changes BobberLineStartPoint distance, so the procedural line becomes shorter/longer.")]
[SerializeField] private bool useReelControl = true;
[Tooltip("Usually the center/pivot of the reel. The controller should move around this point.")]
[SerializeField] private Transform reelCenterPoint;
[Tooltip("Usually the off-hand controller transform, or a reel-handle tracking transform.")]
[SerializeField] private Transform reelRotationSource;
[Tooltip("If On, Reel Hold Action must be pressed to reel. If Off and Use Reel Grab Events is also Off, being near the reel is enough.")]
[SerializeField] private bool useReelHoldAction = true;
[Tooltip("Button/Grip action used while reeling. Example binding: <XRController>{LeftHand}/gripPressed or <XRController>{LeftHand}/triggerPressed.")]
[SerializeField] private InputActionReference reelHoldAction;
[Tooltip("If On, call MarkReelHandleGrabbed/MarkReelHandleReleased from a reel handle XR Grab Interactable or trigger volume.")]
[SerializeField] private bool useReelGrabEvents = false;
[Tooltip("If On, the rod must be held before reeling is allowed.")]
[SerializeField] private bool requireRodHeldToReel = true;
[Tooltip("켜면 입질 이후 또는 미니게임 중일 때만 릴 감기가 됩니다. 기본 낚시가 먼저 되게 하려면 On 추천.")]
[SerializeField] private bool reelOnlyAfterBiteOrMiniGame = true;
[Tooltip("디버그용입니다. Hold Action과 Grab Events가 모두 꺼져 있어도 릴 근처에 있으면 감기게 합니다.")]
[SerializeField] private bool allowReelByProximityOnlyForDebug = false;
[Tooltip("Maximum distance from Reel Center Point to Reel Rotation Source to start reeling.")]
[SerializeField] private float reelActivationRadius = 0.35f;
[Tooltip("Reel axis in Reel Center Point local space. Try Local X first, then Y/Z depending on your reel model.")]
[SerializeField] private Vector3 reelLocalAxis = Vector3.right;
[Tooltip("Invert if turning the handle makes the line longer instead of shorter.")]
[SerializeField] private bool invertReelDirection = false;
[Tooltip("How many meters of line change per full 360 degree reel turn.")]
[SerializeField] private float lineLengthPerReelTurn = 0.45f;
[SerializeField] private float minReelLineLength = 0.35f;
[SerializeField] private float maxReelLineLength = 8f;
[Tooltip("If Off, reverse rotation will not let more line out; it only reels in.")]
[SerializeField] private bool allowReelOut = true;
[Tooltip("Optional visual object to rotate when reeling. This is only visual.")]
[SerializeField] private Transform reelHandleVisual;
[SerializeField] private bool rotateReelHandleVisual = true;
[Tooltip("Reel handle visual axis in its own local space.")]
[SerializeField] private Vector3 reelHandleVisualLocalAxis = Vector3.right;
[SerializeField] private float reelHandleVisualDegreesMultiplier = 1f;
[Tooltip("When a cast finishes, current line length is initialized from rod tip to bobber distance.")]
[SerializeField] private bool initializeLineLengthAfterCast = true;
[SerializeField] private bool showReelDebugLog = false;
[Header("Return")]
[SerializeField] private float returnDuration = 0.35f;
[Header("Debug")]
[SerializeField] private bool showDebugLog = true;
[SerializeField] private bool drawStartAreaGizmo = true;
[SerializeField] private bool drawWaterCheckGizmo = true;
private MeshFilter lineMeshFilter;
private MeshRenderer lineMeshRenderer;
private Mesh lineMesh;
private Material generatedLineMaterial;
private Coroutine bobberMoveRoutine;
private Coroutine biteWaitRoutine;
private Coroutine biteShakeRoutine;
private bool castActionWasEnabled;
private bool returnActionWasEnabled;
private bool reelHoldActionWasEnabled;
private bool startActionWasEnabled;
private BobberState state = BobberState.Idle;
private bool playerInsideStartArea;
private bool bobberInWater;
private bool bobberWorldPinned;
private Vector3 pinnedBobberWorldPosition;
private Vector3 initialBobberLocalPosition;
private Quaternion initialBobberLocalRotation;
private bool hasInitialBobberTransform;
private Vector3 bobberVisualInitialLocalPosition;
private bool hasBobberVisualInitialLocalPosition;
private bool reelHeldByEvent;
private bool reelInteractionActive;
private bool reelAngleInitialized;
private float previousReelAngle;
private float currentLineLength;
private bool hasCurrentLineLength;
private const float Epsilon = 0.000001f;
public bool IsHeld => isHeld;
public bool PlayerInsideStartArea => playerInsideStartArea;
public bool IsBobberInWater => bobberInWater;
public bool IsBiting => state == BobberState.Bite;
public bool IsReeling => reelInteractionActive;
public float CurrentLineLength => hasCurrentLineLength ? currentLineLength : GetCurrentRodToBobberDistance();
public BobberState State => state;
private void Reset()
{
AutoBindCommonReferences();
}
private void Awake()
{
AutoBindCommonReferences();
CacheInitialBobberTransform();
CacheBobberVisualPosition();
EnsureLineMeshComponents(true);
SetStartGuideVisible(false);
}
private void Start()
{
if (returnBobberToRestOnStart)
ReturnBobberToRestInstant();
}
private void OnEnable()
{
RegisterInputAction(castAction, OnCastInput, ref castActionWasEnabled);
RegisterInputAction(returnAction, OnReturnInput, ref returnActionWasEnabled);
RegisterPassiveInputAction(reelHoldAction, ref reelHoldActionWasEnabled);
RegisterInputAction(startAction, OnStartInput, ref startActionWasEnabled);
}
private void OnDisable()
{
UnregisterInputAction(castAction, OnCastInput, castActionWasEnabled);
UnregisterInputAction(returnAction, OnReturnInput, returnActionWasEnabled);
UnregisterPassiveInputAction(reelHoldAction, reelHoldActionWasEnabled);
UnregisterInputAction(startAction, OnStartInput, startActionWasEnabled);
StopAllRoutinesSafe();
SetStartGuideVisible(false);
}
private void OnValidate()
{
startAreaHalfExtents.x = Mathf.Max(0.01f, startAreaHalfExtents.x);
startAreaHalfExtents.y = Mathf.Max(0.01f, startAreaHalfExtents.y);
startAreaHalfExtents.z = Mathf.Max(0.01f, startAreaHalfExtents.z);
lineRadius = Mathf.Max(0.00005f, lineRadius);
lineSegments = Mathf.Clamp(lineSegments, 1, 128);
lineSides = Mathf.Clamp(lineSides, 3, 24);
lineSagAmount = Mathf.Max(0f, lineSagAmount);
lineDistanceSagMultiplier = Mathf.Max(0f, lineDistanceSagMultiplier);
lineMaxSagAmount = Mathf.Max(0f, lineMaxSagAmount);
castDistance = Mathf.Max(0.1f, castDistance);
castHeight = Mathf.Max(0f, castHeight);
castDuration = Mathf.Max(0.01f, castDuration);
waterRaycastStartUp = Mathf.Max(0f, waterRaycastStartUp);
waterRaycastDownDistance = Mathf.Max(0.1f, waterRaycastDownDistance);
bobberFloatHeightOffset = Mathf.Max(0f, bobberFloatHeightOffset);
waterCheckRadius = Mathf.Max(0.001f, waterCheckRadius);
biteDelayRange.x = Mathf.Max(0f, biteDelayRange.x);
biteDelayRange.y = Mathf.Max(biteDelayRange.x, biteDelayRange.y);
biteShakeAmplitude = Mathf.Max(0f, biteShakeAmplitude);
biteShakeFrequency = Mathf.Max(0f, biteShakeFrequency);
biteShakeDuration = Mathf.Max(0f, biteShakeDuration);
reelActivationRadius = Mathf.Max(0.01f, reelActivationRadius);
lineLengthPerReelTurn = Mathf.Max(0.0001f, lineLengthPerReelTurn);
minReelLineLength = Mathf.Max(0.01f, minReelLineLength);
maxReelLineLength = Mathf.Max(minReelLineLength, maxReelLineLength);
if (reelLocalAxis.sqrMagnitude <= Epsilon)
reelLocalAxis = Vector3.right;
if (reelHandleVisualLocalAxis.sqrMagnitude <= Epsilon)
reelHandleVisualLocalAxis = Vector3.right;
returnDuration = Mathf.Max(0.01f, returnDuration);
}
private void Update()
{
UpdateStartAreaState();
UpdateWaterState();
UpdateReelControl();
UpdateBiteVisualPin();
}
private void LateUpdate()
{
if (bobberWorldPinned && bobberLineStartPoint != null)
bobberLineStartPoint.position = GetPinnedBobberPositionWithBiteOffset();
UpdateBobberVisualAttachment();
UpdateProceduralLine();
}
[ContextMenu("Auto Bind Common References")]
public void AutoBindCommonReferences()
{
if (fishingGameManager == null)
fishingGameManager = GetComponentInParent<FishingGameManager>();
if (haptic == null)
haptic = GetComponentInParent<FishingHapticManager>();
if (rodLineEndPoint == null)
rodLineEndPoint = FindChildRecursive(transform, "RodLineEndPoint", "RodTipPoint", "RodTip", "TipPoint");
if (bobberLineStartPoint == null)
bobberLineStartPoint = FindChildRecursive(transform, "BobberLineStartPoint", "BobberPoint", "BobberRoot", "HookPoint");
if (bobberVisualRoot == null && bobberLineStartPoint != null && bobberLineStartPoint.childCount > 0)
bobberVisualRoot = bobberLineStartPoint.GetChild(0);
if (lineMeshRoot == null)
lineMeshRoot = FindChildRecursive(transform, "FishingLine", "FishingLineRoot", "LineRoot", "ProceduralLineRoot");
if (castDirectionSource == null)
castDirectionSource = rodLineEndPoint != null ? rodLineEndPoint : transform;
if (reelCenterPoint == null)
reelCenterPoint = FindChildRecursive(transform, "ReelCenterPoint", "ReelPivot", "ReelHandlePivot", "ReelCenter");
if (reelHandleVisual == null)
reelHandleVisual = FindChildRecursive(transform, "ReelHandleVisual", "ReelHandle", "ReelKnob", "ReelHandleKnob");
}
public void MarkHeld()
{
SetHeld(true);
}
public void MarkReleased()
{
SetHeld(false);
}
public void MarkReelHandleGrabbed()
{
reelHeldByEvent = true;
reelAngleInitialized = false;
LogReel("릴 손잡이 잡음");
}
public void MarkReelHandleReleased()
{
reelHeldByEvent = false;
StopReelInteraction();
LogReel("릴 손잡이 놓음");
}
public void SetHeld(bool value)
{
if (isHeld == value)
return;
isHeld = value;
if (showHeldDebugLog)
Debug.Log(isHeld ? "낚싯대 잡음" : "낚싯대 놓음", this);
}
public void CastForward()
{
if (requireRodHeldToCast && !isHeld)
{
Log("낚싯대를 잡고 있지 않아 캐스팅을 무시했습니다.");
return;
}
if (rodLineEndPoint == null || bobberLineStartPoint == null)
{
Debug.LogWarning("[FishingRodVRController] RodLineEndPoint 또는 BobberLineStartPoint가 없습니다.", this);
return;
}
Vector3 target = CalculateCastTarget();
CastToWorldPoint(target);
}
public void CastToWorldPoint(Vector3 worldTarget)
{
if (bobberLineStartPoint == null)
return;
StopBobberMoveAndBiteRoutines();
bobberWorldPinned = false;
state = BobberState.Casting;
bobberMoveRoutine = StartCoroutine(CastRoutine(worldTarget));
}
public void ReturnBobberToRest()
{
if (bobberLineStartPoint == null)
return;
StopBobberMoveAndBiteRoutines();
bobberWorldPinned = false;
state = BobberState.Returning;
bobberMoveRoutine = StartCoroutine(MoveBobberRoutine(GetRestWorldPosition(), returnDuration, BobberState.Idle));
}
public void ReturnBobberToRestInstant()
{
if (bobberLineStartPoint == null)
return;
StopBobberMoveAndBiteRoutines();
bobberWorldPinned = false;
bobberLineStartPoint.position = GetRestWorldPosition();
InitializeReelLineLengthFromCurrentDistance();
ResetBobberVisualPosition();
state = BobberState.Idle;
bobberInWater = false;
}
public void ForceSetReelLineLength(float length)
{
SetReelLineLength(length);
}
public void ForceMiniGameActiveState()
{
if (state == BobberState.Bite || state == BobberState.WaitingInWater || state == BobberState.Idle)
state = BobberState.MiniGameActive;
}
public bool TryStartFishing()
{
if (fishingGameManager == null)
{
Debug.LogWarning("[FishingRodVRController] FishingGameManager가 연결되지 않았습니다.", this);
return false;
}
if (!allowRestartAfterClear && fishingGameManager.IsMemoryPieceCollected)
{
Log("이미 기억의 조각을 획득해서 낚시 시작을 막았습니다.");
return false;
}
if (requireRodHeldToStart && !isHeld)
{
Log("낚싯대를 잡고 있지 않아 낚시 시작을 막았습니다.");
return false;
}
if (startMode == StartMode.StartActionInArea && useStartAreaCheck && !playerInsideStartArea)
{
Log("플레이어가 낚시 시작 영역 안에 있지 않습니다.");
return false;
}
if (fishingGameManager.IsFishingSessionRunning)
{
Log("낚시 세션이 이미 진행 중입니다.");
return false;
}
fishingGameManager.StartFishing();
state = BobberState.MiniGameActive;
if (hideGuideWhenFishingStarts)
SetStartGuideVisible(false);
Log("낚시 미니게임 시작");
return true;
}
public void ForceBite()
{
TriggerBite();
}
public void StopFishingRodController()
{
StopAllRoutinesSafe();
bobberWorldPinned = false;
state = BobberState.Idle;
SetStartGuideVisible(false);
}
private void OnCastInput(InputAction.CallbackContext context)
{
CastForward();
}
private void OnReturnInput(InputAction.CallbackContext context)
{
ReturnBobberToRest();
}
private void OnStartInput(InputAction.CallbackContext context)
{
if (startMode == StartMode.StartActionInArea || startMode == StartMode.ManualOnly)
TryStartFishing();
}
private IEnumerator CastRoutine(Vector3 target)
{
Vector3 start = bobberLineStartPoint.position;
Vector3 control = (start + target) * 0.5f + Vector3.up * castHeight;
float timer = 0f;
while (timer < castDuration)
{
timer += Time.deltaTime;
float t = Mathf.Clamp01(timer / castDuration);
t = EaseOutCubic(t);
bobberLineStartPoint.position = QuadraticBezier(start, control, target, t);
yield return null;
}
bobberLineStartPoint.position = target;
pinnedBobberWorldPosition = target;
bobberWorldPinned = pinBobberWorldPositionAfterCast;
if (initializeLineLengthAfterCast)
InitializeReelLineLengthFromCurrentDistance();
bobberMoveRoutine = null;
state = BobberState.WaitingInWater;
Log("찌 캐스팅 완료");
if (!useWaterOverlapCheck && useBiteTimer)
StartWaitingForBite();
}
private IEnumerator MoveBobberRoutine(Vector3 target, float duration, BobberState endState)
{
Vector3 start = bobberLineStartPoint.position;
float timer = 0f;
while (timer < duration)
{
timer += Time.deltaTime;
float t = Mathf.Clamp01(timer / duration);
bobberLineStartPoint.position = Vector3.Lerp(start, target, EaseOutCubic(t));
yield return null;
}
bobberLineStartPoint.position = target;
InitializeReelLineLengthFromCurrentDistance();
ResetBobberVisualPosition();
state = endState;
bobberMoveRoutine = null;
}
private void StartWaitingForBite()
{
if (!useBiteTimer)
return;
if (biteWaitRoutine != null || state == BobberState.Bite || state == BobberState.MiniGameActive)
return;
state = BobberState.WaitingInWater;
biteWaitRoutine = StartCoroutine(BiteWaitRoutine());
Log("입질 대기 시작");
}
private IEnumerator BiteWaitRoutine()
{
float delay = UnityEngine.Random.Range(biteDelayRange.x, biteDelayRange.y);
float timer = 0f;
while (timer < delay)
{
timer += Time.deltaTime;
if (useWaterOverlapCheck && !bobberInWater)
{
biteWaitRoutine = null;
yield break;
}
yield return null;
}
biteWaitRoutine = null;
TriggerBite();
}
private void TriggerBite()
{
if (state == BobberState.MiniGameActive)
return;
state = BobberState.Bite;
Log("입질 발생");
if (haptic != null)
haptic.Good();
if (biteShakeRoutine != null)
StopCoroutine(biteShakeRoutine);
biteShakeRoutine = StartCoroutine(BiteShakeRoutine());
if (startMode == StartMode.OnBite && startMiniGameOnBite)
{
if (TryStartFishing() && stopBiteShakeWhenMiniGameStarts && !keepShakingUntilMiniGameStarts)
StopBiteShakeOnly();
}
}
private IEnumerator BiteShakeRoutine()
{
CacheBobberVisualPosition();
float timer = 0f;
while (keepShakingUntilMiniGameStarts || timer < biteShakeDuration)
{
timer += Time.deltaTime;
yield return null;
}
ResetBobberVisualPosition();
biteShakeRoutine = null;
}
private void UpdateBiteVisualPin()
{
// The actual vertical offset is applied through GetPinnedBobberPositionWithBiteOffset()
// so the line end and bobber point always remain identical.
}
private Vector3 GetPinnedBobberPositionWithBiteOffset()
{
if (state != BobberState.Bite || biteShakeAmplitude <= 0f)
return pinnedBobberWorldPosition;
float y = Mathf.Sin(Time.time * biteShakeFrequency) * biteShakeAmplitude;
return pinnedBobberWorldPosition + Vector3.up * y;
}
private void UpdateStartAreaState()
{
if (startMode != StartMode.StartActionInArea || !useStartAreaCheck)
{
playerInsideStartArea = true;
if (startMode != StartMode.StartActionInArea)
SetStartGuideVisible(false);
return;
}
bool inside = IsPlayerInsideStartArea();
playerInsideStartArea = inside;
bool canShow = inside && CanShowStartGuide();
SetStartGuideVisible(canShow);
}
private bool CanShowStartGuide()
{
if (fishingGameManager != null)
{
if (fishingGameManager.IsFishingSessionRunning)
return false;
if (!allowRestartAfterClear && fishingGameManager.IsMemoryPieceCollected)
return false;
}
if (requireRodHeldToStart && !isHeld)
return false;
return true;
}
private bool IsPlayerInsideStartArea()
{
Transform area = startAreaCenter != null ? startAreaCenter : transform;
LayerMask mask = startAreaUsePlayerLayerMask ? startAreaPlayerLayerMask : ~0;
Collider[] hits = Physics.OverlapBox(area.position, startAreaHalfExtents, area.rotation, mask, QueryTriggerInteraction.Collide);
for (int i = 0; i < hits.Length; i++)
{
if (IsPlayerCollider(hits[i]))
return true;
}
return false;
}
private bool IsPlayerCollider(Collider other)
{
if (other == null)
return false;
if (startAreaUsePlayerLayerMask && startAreaPlayerLayerMask.value != 0)
{
Transform currentForLayer = other.transform;
while (currentForLayer != null)
{
if (((1 << currentForLayer.gameObject.layer) & startAreaPlayerLayerMask.value) != 0)
return true;
currentForLayer = currentForLayer.parent;
}
}
if (string.IsNullOrWhiteSpace(playerTag))
return !startAreaUsePlayerLayerMask;
Transform current = other.transform;
while (current != null)
{
if (CompareTagSafe(current.gameObject, playerTag))
return true;
current = current.parent;
}
return false;
}
private void UpdateWaterState()
{
if (!useWaterOverlapCheck || bobberLineStartPoint == null)
return;
bool inWaterNow = IsBobberOverlappingWater();
if (inWaterNow == bobberInWater)
return;
bobberInWater = inWaterNow;
if (bobberInWater)
{
Log("찌가 물에 들어감");
if (state == BobberState.Casting || state == BobberState.WaitingInWater || state == BobberState.Idle)
StartWaitingForBite();
}
else
{
Log("찌가 물에서 나감");
if (state == BobberState.WaitingInWater || state == BobberState.Bite)
{
StopBiteWaitOnly();
StopBiteShakeOnly();
state = BobberState.Idle;
}
}
}
private bool IsBobberOverlappingWater()
{
LayerMask mask = useWaterLayerMask && waterLayerMask.value != 0 ? waterLayerMask : ~0;
Collider[] hits = Physics.OverlapSphere(bobberLineStartPoint.position, waterCheckRadius, mask, QueryTriggerInteraction.Collide);
for (int i = 0; i < hits.Length; i++)
{
Collider hit = hits[i];
if (hit == null)
continue;
bool layerMatch = !useWaterLayerMask || waterLayerMask.value == 0 || ((1 << hit.gameObject.layer) & waterLayerMask.value) != 0;
bool tagMatch = !useWaterTag || string.IsNullOrWhiteSpace(waterTag) || CompareTagSafe(hit.gameObject, waterTag);
if (useWaterLayerMask && useWaterTag && waterLayerMask.value != 0 && !string.IsNullOrWhiteSpace(waterTag))
{
if (layerMatch || tagMatch)
return true;
}
else if (layerMatch && tagMatch)
{
return true;
}
}
return false;
}
private void UpdateReelControl()
{
if (!useReelControl)
{
StopReelInteraction();
return;
}
if (rodLineEndPoint == null || bobberLineStartPoint == null || reelCenterPoint == null || reelRotationSource == null)
{
StopReelInteraction();
return;
}
if (requireRodHeldToReel && !isHeld)
{
StopReelInteraction();
return;
}
if (state == BobberState.Casting || state == BobberState.Returning)
{
StopReelInteraction();
return;
}
if (reelOnlyAfterBiteOrMiniGame && state != BobberState.Bite && state != BobberState.MiniGameActive)
{
StopReelInteraction();
return;
}
if (!ShouldReelInputBeActive())
{
StopReelInteraction();
return;
}
float distanceToReel = Vector3.Distance(reelCenterPoint.position, reelRotationSource.position);
if (distanceToReel > reelActivationRadius)
{
StopReelInteraction();
return;
}
float currentAngle = GetReelSourceAngle();
if (!reelAngleInitialized)
{
previousReelAngle = currentAngle;
reelAngleInitialized = true;
reelInteractionActive = true;
InitializeReelLineLengthFromCurrentDistanceIfNeeded();
LogReel("릴 감기 시작");
return;
}
float deltaAngle = Mathf.DeltaAngle(previousReelAngle, currentAngle);
previousReelAngle = currentAngle;
if (Mathf.Abs(deltaAngle) < 0.01f)
return;
if (invertReelDirection)
deltaAngle = -deltaAngle;
// Positive deltaAngle is treated as reeling in, so line length decreases.
float lengthDelta = -deltaAngle / 360f * lineLengthPerReelTurn;
if (!allowReelOut && lengthDelta > 0f)
lengthDelta = 0f;
SetReelLineLength(CurrentLineLength + lengthDelta);
RotateReelHandleVisual(deltaAngle);
}
private bool ShouldReelInputBeActive()
{
bool actionActive = useReelHoldAction && reelHoldAction != null && reelHoldAction.action != null && reelHoldAction.action.IsPressed();
bool eventActive = useReelGrabEvents && reelHeldByEvent;
if (!useReelHoldAction && !useReelGrabEvents)
return allowReelByProximityOnlyForDebug;
return actionActive || eventActive;
}
private void StopReelInteraction()
{
reelInteractionActive = false;
reelAngleInitialized = false;
}
private float GetReelSourceAngle()
{
Vector3 axis = GetReelWorldAxis();
Vector3 fromCenter = reelRotationSource.position - reelCenterPoint.position;
Vector3 projected = Vector3.ProjectOnPlane(fromCenter, axis);
if (projected.sqrMagnitude <= Epsilon)
return previousReelAngle;
projected.Normalize();
Vector3 reference = Vector3.ProjectOnPlane(reelCenterPoint.up, axis);
if (reference.sqrMagnitude <= Epsilon)
reference = Vector3.ProjectOnPlane(reelCenterPoint.forward, axis);
if (reference.sqrMagnitude <= Epsilon)
reference = Vector3.ProjectOnPlane(Vector3.up, axis);
if (reference.sqrMagnitude <= Epsilon)
reference = Vector3.right;
reference.Normalize();
return Vector3.SignedAngle(reference, projected, axis);
}
private Vector3 GetReelWorldAxis()
{
if (reelCenterPoint == null)
return Vector3.right;
Vector3 localAxis = reelLocalAxis.sqrMagnitude > Epsilon ? reelLocalAxis.normalized : Vector3.right;
Vector3 worldAxis = reelCenterPoint.TransformDirection(localAxis);
return SafeNormalize(worldAxis, reelCenterPoint.right);
}
private void InitializeReelLineLengthFromCurrentDistanceIfNeeded()
{
if (!hasCurrentLineLength)
InitializeReelLineLengthFromCurrentDistance();
}
private void InitializeReelLineLengthFromCurrentDistance()
{
if (rodLineEndPoint == null || bobberLineStartPoint == null)
return;
currentLineLength = Mathf.Clamp(GetCurrentRodToBobberDistance(), minReelLineLength, maxReelLineLength);
hasCurrentLineLength = true;
}
private float GetCurrentRodToBobberDistance()
{
if (rodLineEndPoint == null || bobberLineStartPoint == null)
return 0f;
Vector3 end = bobberWorldPinned ? pinnedBobberWorldPosition : bobberLineStartPoint.position;
return Vector3.Distance(rodLineEndPoint.position, end);
}
private void SetReelLineLength(float targetLength)
{
if (rodLineEndPoint == null || bobberLineStartPoint == null)
return;
currentLineLength = Mathf.Clamp(targetLength, minReelLineLength, maxReelLineLength);
hasCurrentLineLength = true;
Vector3 start = rodLineEndPoint.position;
Vector3 baseEnd = bobberWorldPinned ? pinnedBobberWorldPosition : bobberLineStartPoint.position;
Vector3 direction = baseEnd - start;
if (direction.sqrMagnitude <= Epsilon)
{
Transform source = castDirectionSource != null ? castDirectionSource : transform;
direction = source.forward;
}
direction = SafeNormalize(direction, transform.forward);
Vector3 newEnd = start + direction * currentLineLength;
if (bobberWorldPinned)
pinnedBobberWorldPosition = newEnd;
bobberLineStartPoint.position = newEnd;
if (state == BobberState.Idle && currentLineLength > minReelLineLength + 0.05f)
state = BobberState.WaitingInWater;
LogReel($"릴 줄 길이: {currentLineLength:F2}m");
}
private void RotateReelHandleVisual(float deltaAngle)
{
if (!rotateReelHandleVisual || reelHandleVisual == null)
return;
Vector3 axis = reelHandleVisualLocalAxis.sqrMagnitude > Epsilon ? reelHandleVisualLocalAxis.normalized : Vector3.right;
reelHandleVisual.Rotate(axis, deltaAngle * reelHandleVisualDegreesMultiplier, Space.Self);
}
private Vector3 CalculateCastTarget()
{
Transform source = castDirectionSource != null ? castDirectionSource : (rodLineEndPoint != null ? rodLineEndPoint : transform);
Vector3 direction = source.forward;
if (flattenCastDirection)
{
direction.y = 0f;
if (direction.sqrMagnitude <= Epsilon)
direction = transform.forward;
}
direction = SafeNormalize(direction, transform.forward);
Vector3 fallbackTarget = rodLineEndPoint.position + direction * castDistance;
if (useFixedWaterHeightFallback)
fallbackTarget.y = fixedWaterHeight + bobberFloatHeightOffset;
if (useWaterRaycastTarget && waterRaycastMask.value != 0)
{
Vector3 rayStart = fallbackTarget + Vector3.up * waterRaycastStartUp;
if (Physics.Raycast(rayStart, Vector3.down, out RaycastHit hit, waterRaycastDownDistance, waterRaycastMask, QueryTriggerInteraction.Collide))
return hit.point + Vector3.up * bobberFloatHeightOffset;
}
return fallbackTarget;
}
private Vector3 GetRestWorldPosition()
{
if (bobberRestPoint != null)
return bobberRestPoint.position;
if (hasInitialBobberTransform && bobberLineStartPoint.parent != null)
return bobberLineStartPoint.parent.TransformPoint(initialBobberLocalPosition);
if (rodLineEndPoint != null)
return rodLineEndPoint.position + rodLineEndPoint.TransformDirection(new Vector3(0f, -0.2f, -0.15f));
return transform.position;
}
private void UpdateBobberVisualAttachment()
{
if (!forceBobberVisualToLineEnd || bobberVisualRoot == null || bobberLineStartPoint == null)
return;
bobberVisualRoot.position = bobberLineStartPoint.position
+ bobberVisualWorldOffset
+ bobberLineStartPoint.rotation * bobberVisualLocalOffset;
}
private void CacheInitialBobberTransform()
{
if (bobberLineStartPoint == null)
return;
initialBobberLocalPosition = bobberLineStartPoint.localPosition;
initialBobberLocalRotation = bobberLineStartPoint.localRotation;
hasInitialBobberTransform = true;
}
private void CacheBobberVisualPosition()
{
if (bobberVisualRoot == null || hasBobberVisualInitialLocalPosition)
return;
bobberVisualInitialLocalPosition = bobberVisualRoot.localPosition;
hasBobberVisualInitialLocalPosition = true;
}
private void ResetBobberVisualPosition()
{
if (bobberVisualRoot == null || !hasBobberVisualInitialLocalPosition)
return;
bobberVisualRoot.localPosition = bobberVisualInitialLocalPosition;
}
private void UpdateProceduralLine()
{
if (rodLineEndPoint == null || bobberLineStartPoint == null)
{
SetLineVisible(!hideLineWhenMissingTargets);
return;
}
EnsureLineMeshComponents(true);
if (lineMeshFilter == null || lineMeshRenderer == null || lineMesh == null)
return;
SetLineVisible(true);
if (lineMaterial != null)
lineMeshRenderer.sharedMaterial = lineMaterial;
else
EnsureFallbackLineMaterial();
Vector3 start = rodLineEndPoint.position;
Vector3 end = bobberLineStartPoint.position;
Vector3 control = (start + end) * 0.5f;
if (lineUseSag)
{
float distance = Vector3.Distance(start, end);
Vector3 sagDir = lineSagDirection.sqrMagnitude > Epsilon ? lineSagDirection.normalized : Vector3.down;
float finalSag = Mathf.Clamp(lineSagAmount + distance * lineDistanceSagMultiplier, 0f, lineMaxSagAmount);
control += sagDir * finalSag;
}
BuildProceduralTubeMesh(start, control, end);
if (drawLineDebug)
Debug.DrawLine(start, end, Color.yellow);
}
private void EnsureLineMeshComponents(bool createIfMissing)
{
Transform root = lineMeshRoot != null ? lineMeshRoot : transform;
if (root == null)
return;
if (lineMeshFilter == null)
lineMeshFilter = root.GetComponent<MeshFilter>();
if (lineMeshRenderer == null)
lineMeshRenderer = root.GetComponent<MeshRenderer>();
if (!createIfMissing)
return;
if (lineMeshFilter == null)
lineMeshFilter = root.gameObject.AddComponent<MeshFilter>();
if (lineMeshRenderer == null)
lineMeshRenderer = root.gameObject.AddComponent<MeshRenderer>();
if (lineMesh == null)
{
lineMesh = new Mesh();
lineMesh.name = "VR Fishing Procedural Line";
lineMesh.MarkDynamic();
}
if (lineMeshFilter.sharedMesh != lineMesh)
lineMeshFilter.sharedMesh = lineMesh;
}
private void EnsureFallbackLineMaterial()
{
if (lineMeshRenderer == null)
return;
if (generatedLineMaterial == null)
{
Shader shader = Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null)
shader = Shader.Find("Unlit/Color");
if (shader == null)
shader = Shader.Find("Standard");
generatedLineMaterial = new Material(shader);
generatedLineMaterial.name = "Generated_FishingLine_Material";
generatedLineMaterial.hideFlags = HideFlags.DontSave;
if (generatedLineMaterial.HasProperty("_Color"))
generatedLineMaterial.SetColor("_Color", new Color(0.02f, 0.02f, 0.02f, 1f));
if (generatedLineMaterial.HasProperty("_BaseColor"))
generatedLineMaterial.SetColor("_BaseColor", new Color(0.02f, 0.02f, 0.02f, 1f));
}
lineMeshRenderer.sharedMaterial = generatedLineMaterial;
}
private void SetLineVisible(bool visible)
{
if (lineMeshRenderer != null)
lineMeshRenderer.enabled = visible;
}
private void BuildProceduralTubeMesh(Vector3 worldStart, Vector3 worldControl, Vector3 worldEnd)
{
if (lineMesh == null || lineMeshFilter == null)
return;
Transform meshTransform = lineMeshFilter.transform;
int rings = Mathf.Max(2, lineSegments + 1);
int sides = Mathf.Max(3, lineSides);
int capVertexCount = lineAddEndCaps ? 2 : 0;
int vertexCount = rings * sides + capVertexCount;
int sideTriangleCount = (rings - 1) * sides * 6;
int capTriangleCount = lineAddEndCaps ? sides * 6 : 0;
Vector3[] vertices = new Vector3[vertexCount];
Vector3[] normals = new Vector3[vertexCount];
Vector2[] uvs = new Vector2[vertexCount];
int[] triangles = new int[sideTriangleCount + capTriangleCount];
Vector3 previousNormal = Vector3.zero;
for (int r = 0; r < rings; r++)
{
float t = (float)r / (rings - 1);
Vector3 point = QuadraticBezier(worldStart, worldControl, worldEnd, t);
Vector3 tangent = SafeNormalize(QuadraticBezierTangent(worldStart, worldControl, worldEnd, t), Vector3.forward);
Vector3 normal;
if (r == 0 || previousNormal.sqrMagnitude <= Epsilon)
{
Vector3 reference = Mathf.Abs(Vector3.Dot(tangent, Vector3.up)) > 0.94f ? Vector3.right : Vector3.up;
Vector3 binormalStart = Vector3.Cross(tangent, reference).normalized;
normal = Vector3.Cross(binormalStart, tangent).normalized;
}
else
{
normal = Vector3.ProjectOnPlane(previousNormal, tangent);
if (normal.sqrMagnitude <= Epsilon)
{
Vector3 reference = Mathf.Abs(Vector3.Dot(tangent, Vector3.up)) > 0.94f ? Vector3.right : Vector3.up;
Vector3 binormalFallback = Vector3.Cross(tangent, reference).normalized;
normal = Vector3.Cross(binormalFallback, tangent).normalized;
}
else
{
normal.Normalize();
}
}
Vector3 binormal = Vector3.Cross(tangent, normal).normalized;
previousNormal = normal;
for (int s = 0; s < sides; s++)
{
float angle = Mathf.PI * 2f * s / sides;
Vector3 radial = Mathf.Cos(angle) * normal + Mathf.Sin(angle) * binormal;
Vector3 worldVertex = point + radial * lineRadius;
int index = r * sides + s;
vertices[index] = meshTransform.InverseTransformPoint(worldVertex);
normals[index] = meshTransform.InverseTransformDirection(radial).normalized;
uvs[index] = new Vector2((float)s / sides, t);
}
}
int tri = 0;
for (int r = 0; r < rings - 1; r++)
{
for (int s = 0; s < sides; s++)
{
int a = r * sides + s;
int b = r * sides + (s + 1) % sides;
int c = (r + 1) * sides + s;
int d = (r + 1) * sides + (s + 1) % sides;
triangles[tri++] = a;
triangles[tri++] = c;
triangles[tri++] = b;
triangles[tri++] = b;
triangles[tri++] = c;
triangles[tri++] = d;
}
}
if (lineAddEndCaps)
{
int startCenter = rings * sides;
int endCenter = startCenter + 1;
vertices[startCenter] = meshTransform.InverseTransformPoint(worldStart);
vertices[endCenter] = meshTransform.InverseTransformPoint(worldEnd);
normals[startCenter] = meshTransform.InverseTransformDirection(SafeNormalize(worldStart - worldControl, -Vector3.forward));
normals[endCenter] = meshTransform.InverseTransformDirection(SafeNormalize(worldEnd - worldControl, Vector3.forward));
uvs[startCenter] = new Vector2(0.5f, 0f);
uvs[endCenter] = new Vector2(0.5f, 1f);
for (int s = 0; s < sides; s++)
{
int a = s;
int b = (s + 1) % sides;
triangles[tri++] = startCenter;
triangles[tri++] = b;
triangles[tri++] = a;
int c = (rings - 1) * sides + s;
int d = (rings - 1) * sides + (s + 1) % sides;
triangles[tri++] = endCenter;
triangles[tri++] = c;
triangles[tri++] = d;
}
}
lineMesh.Clear();
lineMesh.vertices = vertices;
lineMesh.normals = normals;
lineMesh.uv = uvs;
lineMesh.triangles = triangles;
lineMesh.RecalculateBounds();
}
private void RegisterPassiveInputAction(InputActionReference actionReference, ref bool wasEnabled)
{
if (actionReference == null || actionReference.action == null)
return;
wasEnabled = actionReference.action.enabled;
if (enableInputActionsManually && !wasEnabled)
actionReference.action.Enable();
}
private void UnregisterPassiveInputAction(InputActionReference actionReference, bool wasEnabled)
{
if (actionReference == null || actionReference.action == null)
return;
if (enableInputActionsManually && !wasEnabled)
actionReference.action.Disable();
}
private void RegisterInputAction(InputActionReference actionReference, Action<InputAction.CallbackContext> callback, ref bool wasEnabled)
{
if (actionReference == null || actionReference.action == null)
return;
wasEnabled = actionReference.action.enabled;
actionReference.action.performed += callback;
if (enableInputActionsManually && !wasEnabled)
actionReference.action.Enable();
}
private void UnregisterInputAction(InputActionReference actionReference, Action<InputAction.CallbackContext> callback, bool wasEnabled)
{
if (actionReference == null || actionReference.action == null)
return;
actionReference.action.performed -= callback;
if (enableInputActionsManually && !wasEnabled)
actionReference.action.Disable();
}
private void StopAllRoutinesSafe()
{
StopBobberMoveAndBiteRoutines();
}
private void StopBobberMoveAndBiteRoutines()
{
if (bobberMoveRoutine != null)
StopCoroutine(bobberMoveRoutine);
bobberMoveRoutine = null;
StopBiteWaitOnly();
StopBiteShakeOnly();
}
private void StopBiteWaitOnly()
{
if (biteWaitRoutine != null)
StopCoroutine(biteWaitRoutine);
biteWaitRoutine = null;
}
private void StopBiteShakeOnly()
{
if (biteShakeRoutine != null)
StopCoroutine(biteShakeRoutine);
biteShakeRoutine = null;
ResetBobberVisualPosition();
}
private void SetStartGuideVisible(bool visible)
{
if (startGuideUI != null && startGuideUI.activeSelf != visible)
startGuideUI.SetActive(visible);
}
private Transform FindChildRecursive(Transform root, params string[] names)
{
if (root == null || names == null)
return null;
for (int i = 0; i < names.Length; i++)
{
if (string.Equals(NormalizeName(root.name), NormalizeName(names[i]), StringComparison.OrdinalIgnoreCase))
return root;
}
for (int i = 0; i < root.childCount; i++)
{
Transform found = FindChildRecursive(root.GetChild(i), names);
if (found != null)
return found;
}
return null;
}
private string NormalizeName(string value)
{
if (string.IsNullOrEmpty(value))
return string.Empty;
return value.Replace(" ", string.Empty)
.Replace("_", string.Empty)
.Replace("-", string.Empty)
.ToLowerInvariant();
}
private bool CompareTagSafe(GameObject obj, string tagName)
{
if (obj == null || string.IsNullOrWhiteSpace(tagName))
return false;
try
{
return obj.CompareTag(tagName);
}
catch
{
return false;
}
}
private Vector3 QuadraticBezier(Vector3 a, Vector3 b, Vector3 c, float t)
{
float u = 1f - t;
return u * u * a + 2f * u * t * b + t * t * c;
}
private Vector3 QuadraticBezierTangent(Vector3 a, Vector3 b, Vector3 c, float t)
{
return 2f * (1f - t) * (b - a) + 2f * t * (c - b);
}
private Vector3 SafeNormalize(Vector3 value, Vector3 fallback)
{
if (value.sqrMagnitude <= Epsilon)
return fallback.sqrMagnitude > Epsilon ? fallback.normalized : Vector3.forward;
return value.normalized;
}
private float EaseOutCubic(float t)
{
t = Mathf.Clamp01(t);
t = 1f - t;
return 1f - t * t * t;
}
private void LogReel(string message)
{
if (showReelDebugLog)
Debug.Log($"[FishingRodVRController/Reel] {message}", this);
}
private void Log(string message)
{
if (showDebugLog)
Debug.Log($"[FishingRodVRController] {message}", this);
}
private void OnDrawGizmosSelected()
{
if (drawStartAreaGizmo && startMode == StartMode.StartActionInArea && useStartAreaCheck)
{
Transform area = startAreaCenter != null ? startAreaCenter : transform;
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.25f);
Matrix4x4 oldMatrix = Gizmos.matrix;
Gizmos.matrix = Matrix4x4.TRS(area.position, area.rotation, Vector3.one);
Gizmos.DrawCube(Vector3.zero, startAreaHalfExtents * 2f);
Gizmos.color = new Color(0.2f, 0.8f, 1f, 1f);
Gizmos.DrawWireCube(Vector3.zero, startAreaHalfExtents * 2f);
Gizmos.matrix = oldMatrix;
}
if (useReelControl && reelCenterPoint != null)
{
Gizmos.color = new Color(1f, 0.65f, 0.15f, 0.7f);
Gizmos.DrawWireSphere(reelCenterPoint.position, reelActivationRadius);
Vector3 axis = reelCenterPoint.TransformDirection(reelLocalAxis.sqrMagnitude > Epsilon ? reelLocalAxis.normalized : Vector3.right);
Gizmos.DrawLine(reelCenterPoint.position - axis * 0.15f, reelCenterPoint.position + axis * 0.15f);
}
if (drawWaterCheckGizmo && bobberLineStartPoint != null)
{
Gizmos.color = new Color(0.2f, 0.6f, 1f, 0.6f);
Gizmos.DrawWireSphere(bobberLineStartPoint.position, waterCheckRadius);
}
}
}