육지도착
This commit is contained in:
160
Assets/02_Scripts/Cave/ClamBiteDetector.cs
Normal file
160
Assets/02_Scripts/Cave/ClamBiteDetector.cs
Normal file
@@ -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<Collider> collidersInside = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (biteZoneCollider == null)
|
||||
biteZoneCollider = GetComponent<Collider>();
|
||||
|
||||
if (biteZoneCollider != null)
|
||||
{
|
||||
biteZoneCollider.isTrigger = true;
|
||||
biteZoneCollider.enabled = false;
|
||||
}
|
||||
|
||||
if (clam == null)
|
||||
clam = GetComponentInParent<ClamOpenClose>();
|
||||
|
||||
if (health == null)
|
||||
health = FindFirstObjectByType<RaftHealth>();
|
||||
|
||||
if (memoryFragment == null)
|
||||
memoryFragment = FindFirstObjectByType<MemoryFragmentReset>();
|
||||
}
|
||||
|
||||
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<XRHandMarker>() != null;
|
||||
bool isFragment = other.CompareTag(fragmentTag) || other.GetComponentInParent<MemoryFragmentReset>() != 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/ClamBiteDetector.cs.meta
Normal file
2
Assets/02_Scripts/Cave/ClamBiteDetector.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9fc0bb91f57b5a74392435f20649c3e9
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
32
Assets/02_Scripts/Cave/DamageObstacle.cs
Normal file
32
Assets/02_Scripts/Cave/DamageObstacle.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/DamageObstacle.cs.meta
Normal file
2
Assets/02_Scripts/Cave/DamageObstacle.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 310877c91d22e1142b0ea9977b24d3dc
|
||||
85
Assets/02_Scripts/Cave/MemoryFragmentReset.cs
Normal file
85
Assets/02_Scripts/Cave/MemoryFragmentReset.cs
Normal file
@@ -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<XRGrabInteractable>();
|
||||
|
||||
if (rb == null)
|
||||
rb = GetComponent<Rigidbody>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/MemoryFragmentReset.cs.meta
Normal file
2
Assets/02_Scripts/Cave/MemoryFragmentReset.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c8bb23fa35921f41b4e864bd95c0c77
|
||||
156
Assets/02_Scripts/Cave/RaftDamageReceiver.cs
Normal file
156
Assets/02_Scripts/Cave/RaftDamageReceiver.cs
Normal file
@@ -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<DamageObstacle, float> lastDamageTimeByObstacle = new();
|
||||
private float lastGlobalDamageTime = -999f;
|
||||
|
||||
private readonly List<InputDevice> hapticDevices = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (raftHealth == null)
|
||||
{
|
||||
raftHealth = GetComponentInParent<RaftHealth>();
|
||||
}
|
||||
|
||||
if (raftHealth == null)
|
||||
{
|
||||
raftHealth = FindFirstObjectByType<RaftHealth>();
|
||||
}
|
||||
}
|
||||
|
||||
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<DamageObstacle>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/RaftDamageReceiver.cs.meta
Normal file
2
Assets/02_Scripts/Cave/RaftDamageReceiver.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d64021aa9910eb4bb87691a2cdc6697
|
||||
82
Assets/02_Scripts/Cave/RaftHealth.cs
Normal file
82
Assets/02_Scripts/Cave/RaftHealth.cs
Normal file
@@ -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<int, int> 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();
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/RaftHealth.cs.meta
Normal file
2
Assets/02_Scripts/Cave/RaftHealth.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a21b5472b91813b44bf392e78726081a
|
||||
77
Assets/02_Scripts/Cave/RaftHealthUI.cs
Normal file
77
Assets/02_Scripts/Cave/RaftHealthUI.cs
Normal file
@@ -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<RaftHealth>();
|
||||
}
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/RaftHealthUI.cs.meta
Normal file
2
Assets/02_Scripts/Cave/RaftHealthUI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33f1269210c01984da23f85d35082e80
|
||||
@@ -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
|
||||
|
||||
238
Assets/02_Scripts/Cave/RaftStartManager.cs
Normal file
238
Assets/02_Scripts/Cave/RaftStartManager.cs
Normal file
@@ -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<RaftRiverController>();
|
||||
|
||||
if (steeringKey == null)
|
||||
steeringKey = FindFirstObjectByType<SteeringKeyXR>();
|
||||
|
||||
if (raftHealth == null)
|
||||
raftHealth = FindFirstObjectByType<RaftHealth>();
|
||||
|
||||
if (rhinos == null || rhinos.Length == 0)
|
||||
rhinos = FindObjectsByType<RhinoObstacle>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/RaftStartManager.cs.meta
Normal file
2
Assets/02_Scripts/Cave/RaftStartManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24e1027c38bc1e34b9b4d80397ad481a
|
||||
310
Assets/02_Scripts/Cave/RhinoObstacle.cs
Normal file
310
Assets/02_Scripts/Cave/RhinoObstacle.cs
Normal file
@@ -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<Animator>();
|
||||
|
||||
if (damageObstacle == null)
|
||||
damageObstacle = GetComponentInChildren<DamageObstacle>();
|
||||
|
||||
if (damageColliders == null || damageColliders.Length == 0)
|
||||
{
|
||||
damageColliders = GetComponentsInChildren<Collider>(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);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/RhinoObstacle.cs.meta
Normal file
2
Assets/02_Scripts/Cave/RhinoObstacle.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1650344929a31bf469215ce025b8fd1d
|
||||
5
Assets/02_Scripts/Cave/XRHandMarker.cs
Normal file
5
Assets/02_Scripts/Cave/XRHandMarker.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class XRHandMarker : MonoBehaviour
|
||||
{
|
||||
}
|
||||
2
Assets/02_Scripts/Cave/XRHandMarker.cs.meta
Normal file
2
Assets/02_Scripts/Cave/XRHandMarker.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 559f6e10a7fe4e2468ff96e9693b444e
|
||||
Reference in New Issue
Block a user