using System; using System.Collections; using UnityEngine; using UnityEngine.InputSystem; /// /// 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) /// 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: {LeftHand}/gripPressed or {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(); if (haptic == null) haptic = GetComponentInParent(); 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(); if (lineMeshRenderer == null) lineMeshRenderer = root.GetComponent(); if (!createIfMissing) return; if (lineMeshFilter == null) lineMeshFilter = root.gameObject.AddComponent(); if (lineMeshRenderer == null) lineMeshRenderer = root.gameObject.AddComponent(); 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 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 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); } } }