From 74afff5be83a8d7fb1e89dfe727c3c13caf3a531 Mon Sep 17 00:00:00 2001 From: rainylinjing-reboot Date: Mon, 22 Jun 2026 16:58:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=A1=EC=A7=80=EB=8F=84=EC=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/01_Scenes/Cave_Test_2.unity | 4 +- Assets/02_Scripts/Cave/ClamBiteDetector.cs | 160 +++++++++ .../02_Scripts/Cave/ClamBiteDetector.cs.meta | 2 + Assets/02_Scripts/Cave/ClamOpenClose.cs | 33 +- Assets/02_Scripts/Cave/DamageObstacle.cs | 32 ++ Assets/02_Scripts/Cave/DamageObstacle.cs.meta | 2 + Assets/02_Scripts/Cave/MemoryFragmentReset.cs | 85 +++++ .../Cave/MemoryFragmentReset.cs.meta | 2 + Assets/02_Scripts/Cave/RaftDamageReceiver.cs | 156 +++++++++ .../Cave/RaftDamageReceiver.cs.meta | 2 + Assets/02_Scripts/Cave/RaftHealth.cs | 82 +++++ Assets/02_Scripts/Cave/RaftHealth.cs.meta | 2 + Assets/02_Scripts/Cave/RaftHealthUI.cs | 77 ++++ Assets/02_Scripts/Cave/RaftHealthUI.cs.meta | 2 + Assets/02_Scripts/Cave/RaftRiverController.cs | 330 ++++++++++++++++-- Assets/02_Scripts/Cave/RaftStartManager.cs | 238 +++++++++++++ .../02_Scripts/Cave/RaftStartManager.cs.meta | 2 + Assets/02_Scripts/Cave/RhinoObstacle.cs | 310 ++++++++++++++++ Assets/02_Scripts/Cave/RhinoObstacle.cs.meta | 2 + Assets/02_Scripts/Cave/XRHandMarker.cs | 5 + Assets/02_Scripts/Cave/XRHandMarker.cs.meta | 2 + Assets/04_Models/Cave/Enemy/Rhinos.controller | 20 +- ProjectSettings/TagManager.asset | 4 +- 23 files changed, 1500 insertions(+), 54 deletions(-) create mode 100644 Assets/02_Scripts/Cave/ClamBiteDetector.cs create mode 100644 Assets/02_Scripts/Cave/ClamBiteDetector.cs.meta create mode 100644 Assets/02_Scripts/Cave/DamageObstacle.cs create mode 100644 Assets/02_Scripts/Cave/DamageObstacle.cs.meta create mode 100644 Assets/02_Scripts/Cave/MemoryFragmentReset.cs create mode 100644 Assets/02_Scripts/Cave/MemoryFragmentReset.cs.meta create mode 100644 Assets/02_Scripts/Cave/RaftDamageReceiver.cs create mode 100644 Assets/02_Scripts/Cave/RaftDamageReceiver.cs.meta create mode 100644 Assets/02_Scripts/Cave/RaftHealth.cs create mode 100644 Assets/02_Scripts/Cave/RaftHealth.cs.meta create mode 100644 Assets/02_Scripts/Cave/RaftHealthUI.cs create mode 100644 Assets/02_Scripts/Cave/RaftHealthUI.cs.meta create mode 100644 Assets/02_Scripts/Cave/RaftStartManager.cs create mode 100644 Assets/02_Scripts/Cave/RaftStartManager.cs.meta create mode 100644 Assets/02_Scripts/Cave/RhinoObstacle.cs create mode 100644 Assets/02_Scripts/Cave/RhinoObstacle.cs.meta create mode 100644 Assets/02_Scripts/Cave/XRHandMarker.cs create mode 100644 Assets/02_Scripts/Cave/XRHandMarker.cs.meta diff --git a/Assets/01_Scenes/Cave_Test_2.unity b/Assets/01_Scenes/Cave_Test_2.unity index 84894c44..7b65bf44 100644 --- a/Assets/01_Scenes/Cave_Test_2.unity +++ b/Assets/01_Scenes/Cave_Test_2.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c1cb02578e3389c0867cb8f16ee5ae0e88bba6d4b11466a7c132c84c51747ebd -size 515947 +oid sha256:26dd174996a95d037bc40437d88cbe2f5a55a2532d6550fed72a51dd0674bddc +size 781114 diff --git a/Assets/02_Scripts/Cave/ClamBiteDetector.cs b/Assets/02_Scripts/Cave/ClamBiteDetector.cs new file mode 100644 index 00000000..f68f7531 --- /dev/null +++ b/Assets/02_Scripts/Cave/ClamBiteDetector.cs @@ -0,0 +1,160 @@ +using System.Collections.Generic; +using UnityEngine; + +public class ClamBiteDetector : MonoBehaviour +{ + [Header("References")] + [SerializeField] private ClamOpenClose clam; + [SerializeField] private RaftHealth health; + [SerializeField] private MemoryFragmentReset memoryFragment; + + [Header("Bite Damage")] + [SerializeField] private int biteDamage = 20; + + [Header("Bite Zone")] + [SerializeField] private Collider biteZoneCollider; + + [Tooltip("조개가 닫히는 동안 이미 한 번 물렸으면 추가 판정을 막습니다.")] + [SerializeField] private bool biteOncePerClose = true; + + [Header("Target Tags")] + [SerializeField] private string handTag = "PlayerHand"; + [SerializeField] private string fragmentTag = "MemoryFragment"; + + [Header("Debug")] + [SerializeField] private bool showDebugLog = true; + + private bool hasBittenThisClose; + private readonly HashSet collidersInside = new(); + + private void Awake() + { + if (biteZoneCollider == null) + biteZoneCollider = GetComponent(); + + if (biteZoneCollider != null) + { + biteZoneCollider.isTrigger = true; + biteZoneCollider.enabled = false; + } + + if (clam == null) + clam = GetComponentInParent(); + + if (health == null) + health = FindFirstObjectByType(); + + if (memoryFragment == null) + memoryFragment = FindFirstObjectByType(); + } + + private void OnEnable() + { + if (clam != null) + { + clam.onCloseStarted.AddListener(EnableBiteWindow); + clam.onClosed.AddListener(DisableBiteWindow); + clam.onOpened.AddListener(ResetBiteState); + } + } + + private void OnDisable() + { + if (clam != null) + { + clam.onCloseStarted.RemoveListener(EnableBiteWindow); + clam.onClosed.RemoveListener(DisableBiteWindow); + clam.onOpened.RemoveListener(ResetBiteState); + } + } + + private void OnTriggerEnter(Collider other) + { + collidersInside.Add(other); + + if (IsBiteWindowOpen()) + TryBite(other); + } + + private void OnTriggerStay(Collider other) + { + collidersInside.Add(other); + + if (IsBiteWindowOpen()) + TryBite(other); + } + + private void OnTriggerExit(Collider other) + { + collidersInside.Remove(other); + } + + private void EnableBiteWindow() + { + hasBittenThisClose = false; + + if (biteZoneCollider != null) + biteZoneCollider.enabled = true; + + if (showDebugLog) + Debug.Log("[ClamBiteDetector] 조개 물림 판정 ON", this); + + foreach (Collider col in collidersInside) + { + if (col != null) + TryBite(col); + } + } + + private void DisableBiteWindow() + { + if (biteZoneCollider != null) + biteZoneCollider.enabled = false; + + collidersInside.Clear(); + + if (showDebugLog) + Debug.Log("[ClamBiteDetector] 조개 물림 판정 OFF", this); + } + + private void ResetBiteState() + { + hasBittenThisClose = false; + } + + private bool IsBiteWindowOpen() + { + if (biteZoneCollider == null) + return false; + + return biteZoneCollider.enabled; + } + + private void TryBite(Collider other) + { + if (other == null) + return; + + if (biteOncePerClose && hasBittenThisClose) + return; + + bool isHand = other.CompareTag(handTag) || other.GetComponentInParent() != null; + bool isFragment = other.CompareTag(fragmentTag) || other.GetComponentInParent() != null; + + if (!isHand && !isFragment) + return; + + hasBittenThisClose = true; + + if (health != null) + health.TakeDamage(biteDamage); + + if (memoryFragment != null) + memoryFragment.ResetFragment(); + + if (showDebugLog) + { + Debug.Log($"[ClamBiteDetector] 조개에게 물림. 데미지 {biteDamage}, 기억의 조각 리셋", this); + } + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/ClamBiteDetector.cs.meta b/Assets/02_Scripts/Cave/ClamBiteDetector.cs.meta new file mode 100644 index 00000000..e7af3bda --- /dev/null +++ b/Assets/02_Scripts/Cave/ClamBiteDetector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9fc0bb91f57b5a74392435f20649c3e9 \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/ClamOpenClose.cs b/Assets/02_Scripts/Cave/ClamOpenClose.cs index bccf9398..b88ae29b 100644 --- a/Assets/02_Scripts/Cave/ClamOpenClose.cs +++ b/Assets/02_Scripts/Cave/ClamOpenClose.cs @@ -1,5 +1,6 @@ using System.Collections; using UnityEngine; +using UnityEngine.Events; public class ClamOpenClose : MonoBehaviour { @@ -22,7 +23,7 @@ public class ClamOpenClose : MonoBehaviour [SerializeField] private float maxOpenTime = 2.0f; [SerializeField] private float openDuration = 1.5f; - [SerializeField] private float closeDuration = 0.18f; + [SerializeField] private float closeDuration = 0.2f; [Header("Motion")] [SerializeField] private AnimationCurve openCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); @@ -32,15 +33,20 @@ public class ClamOpenClose : MonoBehaviour [SerializeField] private bool useSnapClose = true; [Tooltip("닫힐 때 살짝 더 닫히는 오버슈트 각도입니다.")] - [SerializeField] private float snapCloseOvershootX = 5f; + [SerializeField] private float snapCloseOvershootX = 3f; [Tooltip("쾅 하고 닫힌 뒤 원래 닫힌 위치로 돌아오는 시간입니다.")] - [SerializeField] private float snapCloseOvershootDuration = 0.06f; + [SerializeField] private float snapCloseOvershootDuration = 0.02f; [Header("Start Option")] [SerializeField] private bool startAutomatically = true; [SerializeField] private bool startOpened = false; + [Header("Events")] + public UnityEvent onOpened; + public UnityEvent onCloseStarted; + public UnityEvent onClosed; + private Quaternion upClosedRot; private Quaternion downClosedRot; @@ -49,8 +55,10 @@ public class ClamOpenClose : MonoBehaviour private Coroutine routine; private bool isOpen; + private bool isClosing; public bool IsOpen => isOpen; + public bool IsClosing => isClosing; private void Awake() { @@ -72,13 +80,11 @@ private void Awake() return; } - // 현재 Scene에서 맞춰둔 로컬 회전을 닫힌 상태로 저장 upClosedRot = upShell.localRotation; if (downShell != null) downClosedRot = downShell.localRotation; - // 닫힌 상태 기준으로 X축 회전 추가 upOpenedRot = upClosedRot * Quaternion.Euler(upShellOpenX, 0f, 0f); if (downShell != null) @@ -120,7 +126,8 @@ private IEnumerator OpenCloseRoutine() float closedWait = Random.Range(minClosedTime, maxClosedTime); yield return new WaitForSeconds(closedWait); - // 천천히 열기 + isClosing = false; + yield return MoveShells( upClosedRot, upOpenedRot, @@ -131,11 +138,15 @@ private IEnumerator OpenCloseRoutine() ); isOpen = true; + isClosing = false; + onOpened?.Invoke(); float openWait = Random.Range(minOpenTime, maxOpenTime); yield return new WaitForSeconds(openWait); - // 빠르게 닫기 + isClosing = true; + onCloseStarted?.Invoke(); + yield return MoveShells( upOpenedRot, upClosedRot, @@ -147,11 +158,13 @@ private IEnumerator OpenCloseRoutine() isOpen = false; - // 쾅 닫히는 느낌 if (useSnapClose) { yield return SnapCloseEffect(); } + + isClosing = false; + onClosed?.Invoke(); } } @@ -197,7 +210,6 @@ private IEnumerator SnapCloseEffect() if (downShell != null) downSnapRot = downClosedRot * Quaternion.Euler(-snapCloseOvershootX * 0.3f, 0f, 0f); - // 살짝 더 닫힘 if (upShell != null) upShell.localRotation = upSnapRot; @@ -206,7 +218,6 @@ private IEnumerator SnapCloseEffect() yield return new WaitForSeconds(snapCloseOvershootDuration); - // 원래 닫힌 상태로 복귀 if (upShell != null) upShell.localRotation = upClosedRot; @@ -223,6 +234,7 @@ private void SetClosedImmediately() downShell.localRotation = downClosedRot; isOpen = false; + isClosing = false; } private void SetOpenedImmediately() @@ -234,5 +246,6 @@ private void SetOpenedImmediately() downShell.localRotation = downOpenedRot; isOpen = true; + isClosing = false; } } \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/DamageObstacle.cs b/Assets/02_Scripts/Cave/DamageObstacle.cs new file mode 100644 index 00000000..530e2030 --- /dev/null +++ b/Assets/02_Scripts/Cave/DamageObstacle.cs @@ -0,0 +1,32 @@ +using UnityEngine; + +public class DamageObstacle : MonoBehaviour +{ + public enum ObstacleType + { + Rock, + Rhino, + ClamBite + } + + [Header("Damage")] + [SerializeField] private ObstacleType obstacleType = ObstacleType.Rock; + [SerializeField] private int damage = 10; + + [Header("Options")] + [SerializeField] private bool canDamage = true; + + public ObstacleType Type => obstacleType; + public int Damage => damage; + public bool CanDamage => canDamage; + + public void SetCanDamage(bool value) + { + canDamage = value; + } + + public void SetDamage(int value) + { + damage = Mathf.Max(0, value); + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/DamageObstacle.cs.meta b/Assets/02_Scripts/Cave/DamageObstacle.cs.meta new file mode 100644 index 00000000..5b5f4d83 --- /dev/null +++ b/Assets/02_Scripts/Cave/DamageObstacle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 310877c91d22e1142b0ea9977b24d3dc \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/MemoryFragmentReset.cs b/Assets/02_Scripts/Cave/MemoryFragmentReset.cs new file mode 100644 index 00000000..76772fd3 --- /dev/null +++ b/Assets/02_Scripts/Cave/MemoryFragmentReset.cs @@ -0,0 +1,85 @@ +using System.Collections; +using UnityEngine; +using UnityEngine.XR.Interaction.Toolkit.Interactables; + +public class MemoryFragmentReset : MonoBehaviour +{ + [Header("References")] + [SerializeField] private XRGrabInteractable grabInteractable; + [SerializeField] private Rigidbody rb; + + [Header("Reset")] + [SerializeField] private Transform resetPoint; + + [Tooltip("리셋 후 다시 잡을 수 있게 되기까지의 시간")] + [SerializeField] private float reEnableDelay = 0.15f; + + [Header("Debug")] + [SerializeField] private bool showDebugLog = true; + + private Vector3 startPosition; + private Quaternion startRotation; + private Transform startParent; + + private Coroutine resetRoutine; + + private void Awake() + { + if (grabInteractable == null) + grabInteractable = GetComponent(); + + if (rb == null) + rb = GetComponent(); + + startPosition = transform.position; + startRotation = transform.rotation; + startParent = transform.parent; + } + + public void ResetFragment() + { + if (resetRoutine != null) + StopCoroutine(resetRoutine); + + resetRoutine = StartCoroutine(ResetRoutine()); + } + + private IEnumerator ResetRoutine() + { + if (grabInteractable != null) + grabInteractable.enabled = false; + + if (rb != null) + { + rb.linearVelocity = Vector3.zero; + rb.angularVelocity = Vector3.zero; + rb.isKinematic = true; + } + + transform.SetParent(startParent, true); + + if (resetPoint != null) + { + transform.position = resetPoint.position; + transform.rotation = resetPoint.rotation; + } + else + { + transform.position = startPosition; + transform.rotation = startRotation; + } + + yield return new WaitForSeconds(reEnableDelay); + + if (rb != null) + rb.isKinematic = false; + + if (grabInteractable != null) + grabInteractable.enabled = true; + + if (showDebugLog) + Debug.Log("[MemoryFragmentReset] 기억의 조각을 원래 위치로 되돌렸습니다.", this); + + resetRoutine = null; + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/MemoryFragmentReset.cs.meta b/Assets/02_Scripts/Cave/MemoryFragmentReset.cs.meta new file mode 100644 index 00000000..f9420376 --- /dev/null +++ b/Assets/02_Scripts/Cave/MemoryFragmentReset.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c8bb23fa35921f41b4e864bd95c0c77 \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftDamageReceiver.cs b/Assets/02_Scripts/Cave/RaftDamageReceiver.cs new file mode 100644 index 00000000..4e137c64 --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftDamageReceiver.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.XR; + +public class RaftDamageReceiver : MonoBehaviour +{ + [Header("References")] + [SerializeField] private RaftHealth raftHealth; + + [Header("Damage Cooldown")] + [SerializeField] private float sameObstacleDamageCooldown = 1.0f; + [SerializeField] private float globalDamageCooldown = 0.2f; + + [Header("Haptic Feedback")] + [SerializeField] private bool useHapticFeedback = true; + + [Range(0f, 1f)] + [SerializeField] private float hapticAmplitude = 0.6f; + + [SerializeField] private float hapticDuration = 0.15f; + + [Tooltip("체력 데미지 수치에 따라 햅틱 세기를 살짝 키웁니다.")] + [SerializeField] private bool scaleHapticByDamage = true; + + [Header("Debug")] + [SerializeField] private bool showDebugLog = true; + + private readonly Dictionary lastDamageTimeByObstacle = new(); + private float lastGlobalDamageTime = -999f; + + private readonly List hapticDevices = new(); + + private void Awake() + { + if (raftHealth == null) + { + raftHealth = GetComponentInParent(); + } + + if (raftHealth == null) + { + raftHealth = FindFirstObjectByType(); + } + } + + private void OnTriggerEnter(Collider other) + { + TryDamage(other); + } + + private void OnTriggerStay(Collider other) + { + TryDamage(other); + } + + private void TryDamage(Collider other) + { + if (raftHealth == null) + { + if (showDebugLog) + Debug.LogWarning("[RaftDamageReceiver] RaftHealth가 연결되지 않았습니다.", this); + + return; + } + + DamageObstacle obstacle = other.GetComponentInParent(); + + if (obstacle == null) + return; + + if (!obstacle.CanDamage) + return; + + if (raftHealth.IsDead) + return; + + float now = Time.time; + + if (now < lastGlobalDamageTime + globalDamageCooldown) + return; + + if (lastDamageTimeByObstacle.TryGetValue(obstacle, out float lastObstacleTime)) + { + if (now < lastObstacleTime + sameObstacleDamageCooldown) + return; + } + + int damage = obstacle.Damage; + + if (damage <= 0) + return; + + raftHealth.TakeDamage(damage); + + lastGlobalDamageTime = now; + lastDamageTimeByObstacle[obstacle] = now; + + PlayHaptic(damage); + + if (showDebugLog) + { + Debug.Log($"[RaftDamageReceiver] {obstacle.name} 충돌. 데미지: {damage}"); + } + } + + private void PlayHaptic(int damage) + { + if (!useHapticFeedback) + return; + + float amplitude = hapticAmplitude; + + if (scaleHapticByDamage) + { + // 10 데미지 = 기본값, 15 이상 = 조금 더 강하게 + float damageScale = Mathf.Clamp(damage / 10f, 1f, 1.5f); + amplitude *= damageScale; + } + + amplitude = Mathf.Clamp01(amplitude); + + SendHapticToDevice(XRNode.LeftHand, amplitude, hapticDuration); + SendHapticToDevice(XRNode.RightHand, amplitude, hapticDuration); + } + + private void SendHapticToDevice(XRNode node, float amplitude, float duration) + { + InputDevice device = InputDevices.GetDeviceAtXRNode(node); + + if (!device.isValid) + { + if (showDebugLog) + Debug.Log($"[RaftDamageReceiver] {node} 컨트롤러를 찾지 못했습니다."); + + return; + } + + if (!device.TryGetHapticCapabilities(out HapticCapabilities capabilities)) + { + if (showDebugLog) + Debug.Log($"[RaftDamageReceiver] {node} 햅틱 기능을 확인할 수 없습니다."); + + return; + } + + if (!capabilities.supportsImpulse) + { + if (showDebugLog) + Debug.Log($"[RaftDamageReceiver] {node} 컨트롤러가 impulse 햅틱을 지원하지 않습니다."); + + return; + } + + device.SendHapticImpulse(0u, amplitude, duration); + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftDamageReceiver.cs.meta b/Assets/02_Scripts/Cave/RaftDamageReceiver.cs.meta new file mode 100644 index 00000000..7b32c6b5 --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftDamageReceiver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4d64021aa9910eb4bb87691a2cdc6697 \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftHealth.cs b/Assets/02_Scripts/Cave/RaftHealth.cs new file mode 100644 index 00000000..ec9f14eb --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftHealth.cs @@ -0,0 +1,82 @@ +using UnityEngine; +using UnityEngine.Events; + +public class RaftHealth : MonoBehaviour +{ + [Header("Health")] + [SerializeField] private int maxHealth = 100; + [SerializeField] private int currentHealth = 100; + + [Header("Options")] + [SerializeField] private bool resetHealthOnStart = true; + + [Header("Events")] + public UnityEvent onHealthChanged; + public UnityEvent onDead; + + public int MaxHealth => maxHealth; + public int CurrentHealth => currentHealth; + public bool IsDead => currentHealth <= 0; + + private void Start() + { + if (resetHealthOnStart) + { + ResetHealth(); + } + } + + public void ResetHealth() + { + currentHealth = maxHealth; + currentHealth = Mathf.Clamp(currentHealth, 0, maxHealth); + + Debug.Log($"[RaftHealth] 체력 초기화: {currentHealth}/{maxHealth}"); + + onHealthChanged?.Invoke(currentHealth, maxHealth); + } + + public void TakeDamage(int damage) + { + if (damage <= 0) + return; + + if (IsDead) + return; + + currentHealth -= damage; + currentHealth = Mathf.Clamp(currentHealth, 0, maxHealth); + + Debug.Log($"[RaftHealth] 데미지 {damage} 받음. 현재 체력: {currentHealth}/{maxHealth}"); + + onHealthChanged?.Invoke(currentHealth, maxHealth); + + if (currentHealth <= 0) + { + Die(); + } + } + + public void Heal(int amount) + { + if (amount <= 0) + return; + + if (IsDead) + return; + + currentHealth += amount; + currentHealth = Mathf.Clamp(currentHealth, 0, maxHealth); + + Debug.Log($"[RaftHealth] 회복 {amount}. 현재 체력: {currentHealth}/{maxHealth}"); + + onHealthChanged?.Invoke(currentHealth, maxHealth); + } + + private void Die() + { + Debug.Log("[RaftHealth] 체력이 0이 되었습니다. 뗏목 실패 처리."); + + onDead?.Invoke(); + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftHealth.cs.meta b/Assets/02_Scripts/Cave/RaftHealth.cs.meta new file mode 100644 index 00000000..28446eab --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftHealth.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a21b5472b91813b44bf392e78726081a \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftHealthUI.cs b/Assets/02_Scripts/Cave/RaftHealthUI.cs new file mode 100644 index 00000000..2fed89d9 --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftHealthUI.cs @@ -0,0 +1,77 @@ +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +public class RaftHealthUI : MonoBehaviour +{ + [Header("References")] + [SerializeField] private RaftHealth raftHealth; + [SerializeField] private Image hpFillImage; + [SerializeField] private TMP_Text hpText; + + [Header("Text")] + [SerializeField] private string hpPrefix = "HP"; + + [Header("Options")] + [SerializeField] private bool autoFindHealth = true; + [SerializeField] private bool hideWhenNoHealth = false; + + [Header("Debug")] + [SerializeField] private bool showDebugLog = true; + + private void Awake() + { + if (autoFindHealth && raftHealth == null) + { + raftHealth = FindFirstObjectByType(); + } + } + + private void OnEnable() + { + if (raftHealth != null) + { + raftHealth.onHealthChanged.AddListener(UpdateHealthUI); + UpdateHealthUI(raftHealth.CurrentHealth, raftHealth.MaxHealth); + } + else + { + if (hideWhenNoHealth) + gameObject.SetActive(false); + + if (showDebugLog) + Debug.LogWarning("[RaftHealthUI] RaftHealth가 연결되지 않았습니다.", this); + } + } + + private void OnDisable() + { + if (raftHealth != null) + { + raftHealth.onHealthChanged.RemoveListener(UpdateHealthUI); + } + } + + public void UpdateHealthUI(int currentHealth, int maxHealth) + { + if (maxHealth <= 0) + maxHealth = 1; + + float ratio = Mathf.Clamp01((float)currentHealth / maxHealth); + + if (hpFillImage != null) + { + hpFillImage.fillAmount = ratio; + } + + if (hpText != null) + { + hpText.text = $"{currentHealth} / {maxHealth}"; + } + + if (showDebugLog) + { + Debug.Log($"[RaftHealthUI] 체력 UI 갱신: {currentHealth}/{maxHealth}"); + } + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftHealthUI.cs.meta b/Assets/02_Scripts/Cave/RaftHealthUI.cs.meta new file mode 100644 index 00000000..60d50add --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftHealthUI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33f1269210c01984da23f85d35082e80 \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftRiverController.cs b/Assets/02_Scripts/Cave/RaftRiverController.cs index 6441b08c..de485ece 100644 --- a/Assets/02_Scripts/Cave/RaftRiverController.cs +++ b/Assets/02_Scripts/Cave/RaftRiverController.cs @@ -16,6 +16,10 @@ public class RaftRiverController : MonoBehaviour [SerializeField] private float forwardSpeed = 5f; [SerializeField] private float turnSpeed = 4f; + [Header("Start Speed Control")] + [Tooltip("0이면 정지, 1이면 정상 속도입니다. 시작 가속용으로 사용합니다.")] + [SerializeField] private float speedMultiplier = 1f; + [Header("Side Control")] [SerializeField] private float sideMoveSpeed = 16f; [SerializeField] private float sideAcceleration = 40f; @@ -25,8 +29,10 @@ public class RaftRiverController : MonoBehaviour [Header("Path Follow Feel")] [SerializeField] private float pathFollowSmoothTime = 0.28f; + [Range(0f, 1f)] [SerializeField] private float rotationVelocityBlend = 0.45f; + [SerializeField] private float steeringYawAngle = 18f; [Header("Manual Steering")] @@ -38,10 +44,27 @@ public class RaftRiverController : MonoBehaviour [SerializeField] private float arrivalSlowDownDistance = 12f; [SerializeField] private float arrivalMinSpeed = 0.8f; + [Header("Final Stop Guard")] + [Tooltip("마지막 포인트에 가까워지면 거리 판정으로 도착 처리합니다.")] + [SerializeField] private float finalPointReachDistance = 3.0f; + + [Tooltip("마지막 포인트 근처 몇 미터 안에서 지나침 감지를 할지 설정합니다.")] + [SerializeField] private float finalStopGuardDistance = 20f; + + [Tooltip("목표점과의 X/Z 차이가 이 값 이상 다시 커지면 지나친 것으로 판단합니다.")] + [SerializeField] private float finalStopGuardAxisEpsilon = 0.05f; + + [Tooltip("마지막 구간 방향 기준, 마지막 포인트를 넘어가면 즉시 도착 처리합니다.")] + [SerializeField] private bool stopWhenPassedFinalPlane = true; + + [Tooltip("도착 처리 시 마지막 포인트 위치로 뗏목을 스냅할지 여부입니다.")] + [SerializeField] private bool snapToFinalPointOnArrive = true; + [Header("Events")] public UnityEvent onArrived; private int currentPointIndex = 0; + private float sideOffset = 0f; private float sideVelocity = 0f; private float currentSteeringInput = 0f; @@ -53,6 +76,9 @@ public class RaftRiverController : MonoBehaviour private Vector3 previousPosition; private Vector3 startCenterPosition; + private Vector3 previousFinalDelta; + private bool hasPreviousFinalDelta; + private bool isFinished = false; private bool warnedMissingSteeringKey; @@ -76,10 +102,21 @@ private void Start() currentCenterPosition = transform.position; startCenterPosition = currentCenterPosition; + currentForward = transform.forward; - currentRight = transform.right; + currentForward.y = 0f; + + if (currentForward.sqrMagnitude < 0.001f) + currentForward = Vector3.forward; + + currentForward.Normalize(); + + currentRight = Vector3.Cross(Vector3.up, currentForward).normalized; + previousPosition = transform.position; currentPointIndex = 0; + + ResetFinalStopGuard(); } private void Update() @@ -88,24 +125,35 @@ private void Update() return; HandleSideControl(); - MoveAlongPath(); + + bool arrived = MoveAlongPath(); + + if (arrived) + return; + ApplyRaftPositionAndRotation(); } - private void MoveAlongPath() + private bool MoveAlongPath() { SkipMissingPathPoints(); int lastPointIndex = GetLastValidPathPointIndex(); + if (lastPointIndex < 0 || currentPointIndex >= pathPoints.Length) { FinishRaftRide(); - return; + return true; } Transform targetPoint = pathPoints[currentPointIndex]; + + if (targetPoint == null) + return false; + Vector3 toTarget = GetFlatVectorTo(targetPoint.position); float distance = toTarget.magnitude; + bool isLastTarget = currentPointIndex == lastPointIndex; while (!isLastTarget && ShouldAdvancePathPoint(currentPointIndex, distance)) @@ -115,46 +163,56 @@ private void MoveAlongPath() if (currentPointIndex >= pathPoints.Length) { - SnapToPathPoint(targetPoint); - FinishRaftRide(); - return; + FinishAtFinalPoint(); + return true; } targetPoint = pathPoints[currentPointIndex]; + + if (targetPoint == null) + return false; + toTarget = GetFlatVectorTo(targetPoint.position); distance = toTarget.magnitude; isLastTarget = currentPointIndex == lastPointIndex; } - if (isLastTarget && distance <= pointReachDistance) + if (isLastTarget && IsCloseEnoughToFinalPoint(distance)) { - SnapToPathPoint(targetPoint); - FinishRaftRide(); - return; + FinishAtFinalPoint(); + return true; } if (toTarget.sqrMagnitude < 0.001f) - return; + return false; Vector3 pathForward = toTarget.normalized; currentForward = GetTravelForward(pathForward); + float currentSpeed = GetCurrentForwardSpeed(distance); float moveDistance = currentSpeed * Time.deltaTime; if (isLastTarget && moveDistance >= distance) { - SnapToPathPoint(targetPoint); - FinishRaftRide(); - return; + FinishAtFinalPoint(); + return true; } currentCenterPosition += currentForward * moveDistance; - currentRight = Vector3.Cross(Vector3.up, currentForward).normalized; + + if (currentForward.sqrMagnitude > 0.001f) + currentRight = Vector3.Cross(Vector3.up, currentForward).normalized; + + if (TryFinishAtFinalPointByGuards()) + return true; + + return false; } private void HandleSideControl() { float input = 0f; + ResolveSteeringKey(); if (steeringKey != null) @@ -178,6 +236,7 @@ private void HandleSideControl() currentSteeringInput = input; float targetSideVelocity = input * sideMoveSpeed; + sideVelocity = Mathf.MoveTowards( sideVelocity, targetSideVelocity, @@ -202,6 +261,7 @@ private void ApplyRaftPositionAndRotation() targetPosition.y = transform.position.y; float smoothTime = Mathf.Max(0.01f, pathFollowSmoothTime); + transform.position = Vector3.SmoothDamp( transform.position, targetPosition, @@ -215,6 +275,7 @@ private void ApplyRaftPositionAndRotation() frameVelocity.y = 0f; Vector3 lookDirection = currentForward; + if (frameVelocity.sqrMagnitude > 0.0001f) { lookDirection = Vector3.Slerp( @@ -224,6 +285,9 @@ private void ApplyRaftPositionAndRotation() ); } + if (lookDirection.sqrMagnitude < 0.001f) + return; + Quaternion targetRotation = Quaternion.LookRotation(lookDirection, Vector3.up) * Quaternion.Euler(0f, currentSteeringInput * steeringYawAngle, 0f); @@ -236,25 +300,179 @@ private void ApplyRaftPositionAndRotation() } } + private bool IsCloseEnoughToFinalPoint(float distance) + { + float reachDistance = Mathf.Max(pointReachDistance, finalPointReachDistance); + return distance <= reachDistance; + } + + private bool TryFinishAtFinalPointByGuards() + { + int lastPointIndex = GetLastValidPathPointIndex(); + + if (lastPointIndex < 0) + return false; + + if (currentPointIndex != lastPointIndex) + { + ResetFinalStopGuard(); + return false; + } + + Transform finalPoint = pathPoints[lastPointIndex]; + + if (finalPoint == null) + return false; + + Vector3 finalDelta = finalPoint.position - currentCenterPosition; + finalDelta.y = 0f; + + float distanceToFinal = finalDelta.magnitude; + + if (distanceToFinal <= finalPointReachDistance) + { + Debug.Log("[RaftRiverController] Final point reach distance 안에 들어와 도착 처리합니다."); + FinishAtFinalPoint(); + return true; + } + + if (stopWhenPassedFinalPlane && HasPassedFinalPlane()) + { + Debug.Log("[RaftRiverController] 마지막 도착 평면을 지나 도착 처리합니다."); + FinishAtFinalPoint(); + return true; + } + + if (TryFinishIfMovingAwayFromFinalPoint(finalDelta, distanceToFinal)) + return true; + + return false; + } + + private bool HasPassedFinalPlane() + { + int lastPointIndex = GetLastValidPathPointIndex(); + int penultimateIndex = GetPenultimateValidPathPointIndex(); + + if (lastPointIndex < 0 || penultimateIndex < 0 || lastPointIndex == penultimateIndex) + return false; + + Transform finalPoint = pathPoints[lastPointIndex]; + Transform previousPoint = pathPoints[penultimateIndex]; + + if (finalPoint == null || previousPoint == null) + return false; + + Vector3 previousPos = previousPoint.position; + Vector3 finalPos = finalPoint.position; + Vector3 centerPos = currentCenterPosition; + + previousPos.y = 0f; + finalPos.y = 0f; + centerPos.y = 0f; + + Vector3 finalSegmentDirection = finalPos - previousPos; + + if (finalSegmentDirection.sqrMagnitude < 0.001f) + return false; + + finalSegmentDirection.Normalize(); + + Vector3 fromFinalToRaft = centerPos - finalPos; + + float passedAmount = Vector3.Dot(fromFinalToRaft, finalSegmentDirection); + + return passedAmount >= 0f; + } + + private bool TryFinishIfMovingAwayFromFinalPoint(Vector3 finalDelta, float distanceToFinal) + { + if (distanceToFinal > finalStopGuardDistance) + { + previousFinalDelta = finalDelta; + hasPreviousFinalDelta = true; + return false; + } + + if (!hasPreviousFinalDelta) + { + previousFinalDelta = finalDelta; + hasPreviousFinalDelta = true; + return false; + } + + Vector3 previousAbs = new Vector3( + Mathf.Abs(previousFinalDelta.x), + 0f, + Mathf.Abs(previousFinalDelta.z) + ); + + Vector3 currentAbs = new Vector3( + Mathf.Abs(finalDelta.x), + 0f, + Mathf.Abs(finalDelta.z) + ); + + bool xMovingAway = currentAbs.x > previousAbs.x + finalStopGuardAxisEpsilon; + bool zMovingAway = currentAbs.z > previousAbs.z + finalStopGuardAxisEpsilon; + + if (xMovingAway || zMovingAway) + { + Debug.Log("[RaftRiverController] 마지막 포인트에서 멀어지는 것으로 판단하여 도착 처리합니다."); + FinishAtFinalPoint(); + return true; + } + + previousFinalDelta = finalDelta; + return false; + } + + private void FinishAtFinalPoint() + { + int lastPointIndex = GetLastValidPathPointIndex(); + + if (lastPointIndex >= 0 && pathPoints[lastPointIndex] != null && snapToFinalPointOnArrive) + { + SnapToPathPoint(pathPoints[lastPointIndex]); + } + + FinishRaftRide(); + } + private void FinishRaftRide() { if (isFinished) return; isFinished = true; + SetSpeedMultiplier(0f); + + sideVelocity = 0f; + currentSteeringInput = 0f; + positionSmoothVelocity = Vector3.zero; Debug.Log("[RaftRiverController] Arrived at destination. Raft stopped."); + onArrived?.Invoke(); } public void StopRaft() { isFinished = true; + sideVelocity = 0f; + currentSteeringInput = 0f; + positionSmoothVelocity = Vector3.zero; } public void ResumeRaft() { isFinished = false; + ResetFinalStopGuard(); + } + + public void SetSpeedMultiplier(float value) + { + speedMultiplier = Mathf.Clamp01(value); } public void SetSteeringKey(SteeringKeyXR newSteeringKey) @@ -286,7 +504,11 @@ private Vector3 GetTravelForward(Vector3 fallbackForward) if (steeringKey != null && steeringKey.IsGrabbed) { - float turnAmount = currentSteeringInput * Mathf.Max(0f, grabbedSteeringTurnSpeed) * Time.deltaTime; + float turnAmount = + currentSteeringInput * + Mathf.Max(0f, grabbedSteeringTurnSpeed) * + Time.deltaTime; + travelForward = Quaternion.Euler(0f, turnAmount, 0f) * travelForward.normalized; } @@ -307,15 +529,22 @@ private bool ShouldAdvancePathPoint(int targetIndex, float distanceToTarget) private bool HasPassedPathPoint(int targetIndex) { - if (pathPoints == null || targetIndex < 0 || targetIndex >= pathPoints.Length || pathPoints[targetIndex] == null) + if (pathPoints == null || + targetIndex < 0 || + targetIndex >= pathPoints.Length || + pathPoints[targetIndex] == null) + { return true; + } Vector3 anchorPosition = GetPreviousPathAnchorPosition(targetIndex); Vector3 targetPosition = pathPoints[targetIndex].position; + anchorPosition.y = currentCenterPosition.y; targetPosition.y = currentCenterPosition.y; Vector3 segment = targetPosition - anchorPosition; + if (segment.sqrMagnitude < 0.001f) return false; @@ -348,12 +577,16 @@ private void SnapToPathPoint(Transform point) finalPosition.y = transform.position.y; currentCenterPosition = finalPosition; + sideOffset = 0f; sideVelocity = 0f; currentSteeringInput = 0f; positionSmoothVelocity = Vector3.zero; previousPosition = finalPosition; + transform.position = finalPosition; + + ResetFinalStopGuard(); } private int GetPenultimateValidPathPointIndex() @@ -383,18 +616,25 @@ private int GetPenultimateValidPathPointIndex() private float GetArrivalSlowDownDistance() { float fallbackDistance = Mathf.Max(pointReachDistance + 0.01f, arrivalSlowDownDistance); + int penultimateIndex = GetPenultimateValidPathPointIndex(); int lastPointIndex = GetLastValidPathPointIndex(); - if (penultimateIndex < 0 || lastPointIndex < 0 || penultimateIndex == lastPointIndex) + if (penultimateIndex < 0 || + lastPointIndex < 0 || + penultimateIndex == lastPointIndex) + { return fallbackDistance; + } Vector3 penultimatePosition = pathPoints[penultimateIndex].position; Vector3 finalPosition = pathPoints[lastPointIndex].position; + penultimatePosition.y = 0f; finalPosition.y = 0f; float finalSegmentDistance = Vector3.Distance(penultimatePosition, finalPosition); + if (finalSegmentDistance <= pointReachDistance) return fallbackDistance; @@ -403,22 +643,46 @@ private float GetArrivalSlowDownDistance() private float GetCurrentForwardSpeed(float distanceToTarget) { + float baseSpeed; + if (currentPointIndex != GetLastValidPathPointIndex()) - return forwardSpeed; + { + baseSpeed = forwardSpeed; + } + else + { + float maxSpeed = Mathf.Max(0f, forwardSpeed); - float maxSpeed = Mathf.Max(0f, forwardSpeed); - if (maxSpeed <= 0.01f) - return maxSpeed; + if (maxSpeed <= 0.01f) + { + baseSpeed = maxSpeed; + } + else + { + float slowDownDistance = GetArrivalSlowDownDistance(); - float slowDownDistance = GetArrivalSlowDownDistance(); - if (slowDownDistance <= pointReachDistance) - return maxSpeed; + if (slowDownDistance <= pointReachDistance) + { + baseSpeed = maxSpeed; + } + else + { + float minSpeed = Mathf.Clamp(arrivalMinSpeed, 0.01f, maxSpeed); - float minSpeed = Mathf.Clamp(arrivalMinSpeed, 0.01f, maxSpeed); - float speedRatio = Mathf.InverseLerp(pointReachDistance, slowDownDistance, distanceToTarget); - speedRatio = speedRatio * speedRatio * (3f - 2f * speedRatio); + float speedRatio = Mathf.InverseLerp( + pointReachDistance, + slowDownDistance, + distanceToTarget + ); - return Mathf.Lerp(minSpeed, maxSpeed, speedRatio); + speedRatio = speedRatio * speedRatio * (3f - 2f * speedRatio); + + baseSpeed = Mathf.Lerp(minSpeed, maxSpeed, speedRatio); + } + } + } + + return baseSpeed * speedMultiplier; } private int GetLastValidPathPointIndex() @@ -443,6 +707,12 @@ private void SkipMissingPathPoints() } } + private void ResetFinalStopGuard() + { + previousFinalDelta = Vector3.zero; + hasPreviousFinalDelta = false; + } + private float ReadLegacyHorizontalInput() { #if ENABLE_LEGACY_INPUT_MANAGER diff --git a/Assets/02_Scripts/Cave/RaftStartManager.cs b/Assets/02_Scripts/Cave/RaftStartManager.cs new file mode 100644 index 00000000..18091d65 --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftStartManager.cs @@ -0,0 +1,238 @@ +using System.Collections; +using UnityEngine; + +public class RaftStartManager : MonoBehaviour +{ + private enum StartState + { + Ready, + WaitingForKeyGrab, + Starting, + Riding, + Arrived, + Failed + } + + [Header("References")] + [SerializeField] private RaftRiverController raftController; + [SerializeField] private SteeringKeyXR steeringKey; + + [Tooltip("현재는 캡슐 요정. 나중에 요정 캐릭터 모델로 교체할 부모 오브젝트를 넣으면 됩니다.")] + [SerializeField] private GameObject fairyObject; + + [Header("Obstacles")] + [Tooltip("뗏목 출발 시 함께 작동할 코뿔소들입니다.")] + [SerializeField] private RhinoObstacle[] rhinos; + + [Header("Health")] + [SerializeField] private RaftHealth raftHealth; + + [Header("Start Settings")] + [SerializeField] private bool waitForKeyGrab = true; + + [Tooltip("키를 잡은 뒤 뗏목이 완전히 출발 속도에 도달하기까지 걸리는 시간")] + [SerializeField] private float startAccelerationDuration = 2.0f; + + [Tooltip("출발 직후 바로 요정이 사라질지 여부")] + [SerializeField] private bool hideFairyOnStart = true; + + [Header("Debug")] + [SerializeField] private bool showDebugLog = true; + + private StartState state = StartState.Ready; + private Coroutine startRoutine; + + private void Start() + { + SetupStartState(); + } + + private void Update() + { + if (state != StartState.WaitingForKeyGrab) + return; + + if (!waitForKeyGrab) + return; + + if (steeringKey != null && steeringKey.IsGrabbed) + { + BeginRaftRide(); + } + } + + private void SetupStartState() + { + ResolveReferences(); + + state = StartState.WaitingForKeyGrab; + + if (raftController != null) + { + raftController.StopRaft(); + raftController.SetSpeedMultiplier(0f); + } + + if (raftHealth != null) + { + raftHealth.ResetHealth(); + } + + StopAllRhinos(); + + if (fairyObject != null) + { + fairyObject.SetActive(true); + } + + if (showDebugLog) + { + Debug.Log("[RaftStartManager] 시작 준비 완료. 키를 잡으면 뗏목이 출발합니다."); + } + } + + private void ResolveReferences() + { + if (raftController == null) + raftController = FindFirstObjectByType(); + + if (steeringKey == null) + steeringKey = FindFirstObjectByType(); + + if (raftHealth == null) + raftHealth = FindFirstObjectByType(); + + if (rhinos == null || rhinos.Length == 0) + rhinos = FindObjectsByType(FindObjectsSortMode.None); + } + + public void BeginRaftRide() + { + if (state == StartState.Starting || state == StartState.Riding) + return; + + state = StartState.Starting; + + if (hideFairyOnStart && fairyObject != null) + { + fairyObject.SetActive(false); + } + + if (startRoutine != null) + StopCoroutine(startRoutine); + + startRoutine = StartCoroutine(StartRaftSmoothly()); + } + + private IEnumerator StartRaftSmoothly() + { + if (raftController == null) + { + Debug.LogWarning("[RaftStartManager] RaftRiverController가 연결되지 않았습니다.", this); + yield break; + } + + raftController.SetSpeedMultiplier(0f); + raftController.ResumeRaft(); + + StartAllRhinos(); + + if (showDebugLog) + { + Debug.Log("[RaftStartManager] 뗏목 출발 시작. 코뿔소 장애물도 시작합니다."); + } + + float timer = 0f; + float duration = Mathf.Max(0.01f, startAccelerationDuration); + + while (timer < duration) + { + timer += Time.deltaTime; + + float t = Mathf.Clamp01(timer / duration); + float smoothT = t * t * (3f - 2f * t); + + raftController.SetSpeedMultiplier(smoothT); + + yield return null; + } + + raftController.SetSpeedMultiplier(1f); + + state = StartState.Riding; + + if (showDebugLog) + { + Debug.Log("[RaftStartManager] 뗏목 정상 운항 속도 도달."); + } + } + + public void OnRaftArrived() + { + if (state == StartState.Arrived) + return; + + state = StartState.Arrived; + + if (raftController != null) + { + raftController.SetSpeedMultiplier(0f); + } + + StopAllRhinos(); + + if (showDebugLog) + { + Debug.Log("[RaftStartManager] 목적지 도착. 뗏목 구간 종료. 코뿔소 장애물 정지."); + } + } + + public void OnRaftFailed() + { + if (state == StartState.Failed) + return; + + state = StartState.Failed; + + if (raftController != null) + { + raftController.StopRaft(); + raftController.SetSpeedMultiplier(0f); + } + + StopAllRhinos(); + + if (showDebugLog) + { + Debug.Log("[RaftStartManager] 체력 0. 뗏목 구간 실패. 코뿔소 장애물 정지."); + } + } + + private void StartAllRhinos() + { + if (rhinos == null) + return; + + foreach (RhinoObstacle rhino in rhinos) + { + if (rhino == null) + continue; + + rhino.StartRhino(); + } + } + + private void StopAllRhinos() + { + if (rhinos == null) + return; + + foreach (RhinoObstacle rhino in rhinos) + { + if (rhino == null) + continue; + + rhino.StopRhino(); + } + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RaftStartManager.cs.meta b/Assets/02_Scripts/Cave/RaftStartManager.cs.meta new file mode 100644 index 00000000..689c51a6 --- /dev/null +++ b/Assets/02_Scripts/Cave/RaftStartManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 24e1027c38bc1e34b9b4d80397ad481a \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RhinoObstacle.cs b/Assets/02_Scripts/Cave/RhinoObstacle.cs new file mode 100644 index 00000000..b6a3e417 --- /dev/null +++ b/Assets/02_Scripts/Cave/RhinoObstacle.cs @@ -0,0 +1,310 @@ +using System.Collections; +using UnityEngine; + +public class RhinoObstacle : MonoBehaviour +{ + [Header("References")] + [SerializeField] private Transform rhinoRoot; + [SerializeField] private Animator animator; + [SerializeField] private DamageObstacle damageObstacle; + + [Tooltip("수면 위에 있을 때만 켤 코뿔소 충돌 콜라이더들입니다. RhinoHitBox의 Collider를 넣으세요.")] + [SerializeField] private Collider[] damageColliders; + + [Header("Position")] + [SerializeField] private Transform underwaterPoint; + [SerializeField] private Transform surfacePoint; + + [Header("Timing")] + [SerializeField] private float minHiddenTime = 2.0f; + [SerializeField] private float maxHiddenTime = 5.0f; + + [SerializeField] private float riseDuration = 0.8f; + [SerializeField] private float surfaceIdleTime = 0.4f; + [SerializeField] private float attackStayTime = 1.2f; + [SerializeField] private float diveDuration = 0.7f; + + [Header("Animation")] + [SerializeField] private string idleStateName = "Idle"; + [SerializeField] private string hitTriggerName = "Hit"; + [SerializeField] private float idleCrossFadeDuration = 0.1f; + + [Header("Options")] + [SerializeField] private bool startAutomatically = true; + [SerializeField] private bool loop = true; + [SerializeField] private bool showDebugLog = true; + + private Coroutine routine; + private bool isRunning; + private bool isSurfaced; + + public bool IsSurfaced => isSurfaced; + + private void Awake() + { + ResolveReferences(); + + if (animator != null) + { + animator.applyRootMotion = false; + } + + SetDamageActive(false); + } + + private void Start() + { + MoveImmediatelyToUnderwater(); + ForceIdleAnimation(); + + if (startAutomatically) + { + StartRhino(); + } + } + + private void ResolveReferences() + { + if (rhinoRoot == null) + rhinoRoot = transform; + + if (animator == null) + animator = GetComponentInChildren(); + + if (damageObstacle == null) + damageObstacle = GetComponentInChildren(); + + if (damageColliders == null || damageColliders.Length == 0) + { + damageColliders = GetComponentsInChildren(true); + } + } + + public void StartRhino() + { + if (routine != null) + { + StopCoroutine(routine); + } + + isRunning = true; + routine = StartCoroutine(RhinoRoutine()); + + Log("시작"); + } + + public void StopRhino() + { + isRunning = false; + + if (routine != null) + { + StopCoroutine(routine); + routine = null; + } + + SetDamageActive(false); + ForceIdleAnimation(); + MoveImmediatelyToUnderwater(); + + Log("정지"); + } + + private IEnumerator RhinoRoutine() + { + while (isRunning) + { + // 1. 물속 대기 + isSurfaced = false; + SetDamageActive(false); + ForceIdleAnimation(); + + float hiddenWait = Random.Range(minHiddenTime, maxHiddenTime); + Log($"물속 대기 {hiddenWait:0.0}초"); + + yield return new WaitForSeconds(hiddenWait); + + if (!isRunning) + break; + + // 2. 수면 위로 떠오름 + Log("떠오름 시작"); + yield return MoveToSurface(); + + if (!isRunning) + break; + + // 3. 수면 위에 도착한 순간부터 충돌 가능 + isSurfaced = true; + SetDamageActive(true); + ForceIdleAnimation(); + + Log("수면 위 도착 / 데미지 콜라이더 ON"); + + yield return new WaitForSeconds(surfaceIdleTime); + + if (!isRunning) + break; + + // 4. 공격 실행 + Log("공격 시작"); + PlayHitAnimation(); + + yield return new WaitForSeconds(attackStayTime); + + // 5. 공격 종료 후 Idle 복귀 + Log("공격 종료 / Idle 복귀"); + ForceIdleAnimation(); + + if (!isRunning) + break; + + // 6. 잠수 시작 전 충돌 끄기 + isSurfaced = false; + SetDamageActive(false); + + Log("잠수 시작 / 데미지 콜라이더 OFF"); + yield return MoveToUnderwater(); + + if (!loop) + break; + } + + routine = null; + } + + private IEnumerator MoveToSurface() + { + if (rhinoRoot == null || surfacePoint == null) + yield break; + + Vector3 startPos = rhinoRoot.position; + Vector3 endPos = surfacePoint.position; + + float timer = 0f; + float duration = Mathf.Max(0.01f, riseDuration); + + while (timer < duration) + { + timer += Time.deltaTime; + + float t = Mathf.Clamp01(timer / duration); + float smoothT = Smooth01(t); + + rhinoRoot.position = Vector3.Lerp(startPos, endPos, smoothT); + + yield return null; + } + + rhinoRoot.position = endPos; + } + + private IEnumerator MoveToUnderwater() + { + if (rhinoRoot == null || underwaterPoint == null) + yield break; + + Vector3 startPos = rhinoRoot.position; + Vector3 endPos = underwaterPoint.position; + + float timer = 0f; + float duration = Mathf.Max(0.01f, diveDuration); + + while (timer < duration) + { + timer += Time.deltaTime; + + float t = Mathf.Clamp01(timer / duration); + float smoothT = Smooth01(t); + + rhinoRoot.position = Vector3.Lerp(startPos, endPos, smoothT); + + yield return null; + } + + rhinoRoot.position = endPos; + + isSurfaced = false; + SetDamageActive(false); + ForceIdleAnimation(); + + Log("잠수 완료"); + } + + private void PlayHitAnimation() + { + if (animator == null) + { + LogWarning("Animator가 없습니다."); + return; + } + + if (string.IsNullOrEmpty(hitTriggerName)) + return; + + animator.ResetTrigger(hitTriggerName); + animator.SetTrigger(hitTriggerName); + } + + private void ForceIdleAnimation() + { + if (animator == null) + return; + + if (!string.IsNullOrEmpty(hitTriggerName)) + { + animator.ResetTrigger(hitTriggerName); + } + + if (!string.IsNullOrEmpty(idleStateName)) + { + animator.CrossFade(idleStateName, idleCrossFadeDuration, 0, 0f); + } + } + + private void MoveImmediatelyToUnderwater() + { + if (rhinoRoot == null || underwaterPoint == null) + return; + + rhinoRoot.position = underwaterPoint.position; + isSurfaced = false; + SetDamageActive(false); + } + + private void SetDamageActive(bool active) + { + if (damageObstacle != null) + { + damageObstacle.SetCanDamage(active); + } + + if (damageColliders == null) + return; + + foreach (Collider col in damageColliders) + { + if (col == null) + continue; + + col.enabled = active; + } + } + + private float Smooth01(float t) + { + return t * t * (3f - 2f * t); + } + + private void Log(string message) + { + if (showDebugLog) + Debug.Log($"[RhinoObstacle] {name} / {message}"); + } + + private void LogWarning(string message) + { + if (showDebugLog) + Debug.LogWarning($"[RhinoObstacle] {name} / {message}", this); + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/RhinoObstacle.cs.meta b/Assets/02_Scripts/Cave/RhinoObstacle.cs.meta new file mode 100644 index 00000000..0fe484f1 --- /dev/null +++ b/Assets/02_Scripts/Cave/RhinoObstacle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1650344929a31bf469215ce025b8fd1d \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/XRHandMarker.cs b/Assets/02_Scripts/Cave/XRHandMarker.cs new file mode 100644 index 00000000..d76a815a --- /dev/null +++ b/Assets/02_Scripts/Cave/XRHandMarker.cs @@ -0,0 +1,5 @@ +using UnityEngine; + +public class XRHandMarker : MonoBehaviour +{ +} \ No newline at end of file diff --git a/Assets/02_Scripts/Cave/XRHandMarker.cs.meta b/Assets/02_Scripts/Cave/XRHandMarker.cs.meta new file mode 100644 index 00000000..b4c047e3 --- /dev/null +++ b/Assets/02_Scripts/Cave/XRHandMarker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 559f6e10a7fe4e2468ff96e9693b444e \ No newline at end of file diff --git a/Assets/04_Models/Cave/Enemy/Rhinos.controller b/Assets/04_Models/Cave/Enemy/Rhinos.controller index 75d529d5..c7d05897 100644 --- a/Assets/04_Models/Cave/Enemy/Rhinos.controller +++ b/Assets/04_Models/Cave/Enemy/Rhinos.controller @@ -7,11 +7,11 @@ AnimatorState: m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_Name: WhiteRhino_Skelmesh|Rhino_Combat_Atk_Hit + m_Name: Hit m_Speed: 1 m_CycleOffset: 0 m_Transitions: - - {fileID: 8273947539310789631} + - {fileID: 5211168254593058196} m_StateMachineBehaviours: [] m_Position: {x: 50, y: 50, z: 0} m_IKOnFeet: 0 @@ -36,7 +36,7 @@ AnimatorStateTransition: m_Name: m_Conditions: - m_ConditionMode: 1 - m_ConditionEvent: isIdle + m_ConditionEvent: Hit m_EventTreshold: 0 m_DstStateMachine: {fileID: 0} m_DstState: {fileID: -5762510348686555522} @@ -47,7 +47,7 @@ AnimatorStateTransition: m_TransitionDuration: 0.25 m_TransitionOffset: 0 m_ExitTime: 0.95 - m_HasExitTime: 1 + m_HasExitTime: 0 m_HasFixedDuration: 1 m_InterruptionSource: 0 m_OrderedInterruption: 1 @@ -63,7 +63,7 @@ AnimatorStateMachine: m_ChildStates: - serializedVersion: 1 m_State: {fileID: 2482721225991305447} - m_Position: {x: 330, y: 310, z: 0} + m_Position: {x: 300, y: 250, z: 0} - serializedVersion: 1 m_State: {fileID: -5762510348686555522} m_Position: {x: 670, y: 290, z: 0} @@ -86,8 +86,8 @@ AnimatorController: m_Name: Rhinos serializedVersion: 5 m_AnimatorParameters: - - m_Name: isIdle - m_Type: 4 + - m_Name: Hit + m_Type: 9 m_DefaultFloat: 0 m_DefaultInt: 0 m_DefaultBool: 0 @@ -112,7 +112,7 @@ AnimatorState: m_CorrespondingSourceObject: {fileID: 0} m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} - m_Name: WhiteRhino_Skelmesh|AA_WhiteRhino_Loco_Idle_Normal + m_Name: Idle m_Speed: 1 m_CycleOffset: 0 m_Transitions: @@ -132,7 +132,7 @@ AnimatorState: m_MirrorParameter: m_CycleOffsetParameter: m_TimeParameter: ---- !u!1101 &8273947539310789631 +--- !u!1101 &5211168254593058196 AnimatorStateTransition: m_ObjectHideFlags: 1 m_CorrespondingSourceObject: {fileID: 0} @@ -141,7 +141,7 @@ AnimatorStateTransition: m_Name: m_Conditions: [] m_DstStateMachine: {fileID: 0} - m_DstState: {fileID: -5762510348686555522} + m_DstState: {fileID: 2482721225991305447} m_Solo: 0 m_Mute: 0 m_IsExit: 0 diff --git a/ProjectSettings/TagManager.asset b/ProjectSettings/TagManager.asset index f630d63a..5b0750c9 100644 --- a/ProjectSettings/TagManager.asset +++ b/ProjectSettings/TagManager.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64aa6f543ab2a1c95dcee2271529790db755f58c32b79dbd367a63cc435a2a32 -size 574 +oid sha256:72f5bca46f6833504155b6bce7beee8b307eeb279ecef4e33c23090b107c5e4e +size 608