육지도착

This commit is contained in:
2026-06-22 16:58:32 +09:00
parent f7a71e11d6
commit 74afff5be8
23 changed files with 1500 additions and 54 deletions

Binary file not shown.

View 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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9fc0bb91f57b5a74392435f20649c3e9

View File

@@ -1,5 +1,6 @@
using System.Collections; using System.Collections;
using UnityEngine; using UnityEngine;
using UnityEngine.Events;
public class ClamOpenClose : MonoBehaviour public class ClamOpenClose : MonoBehaviour
{ {
@@ -22,7 +23,7 @@ public class ClamOpenClose : MonoBehaviour
[SerializeField] private float maxOpenTime = 2.0f; [SerializeField] private float maxOpenTime = 2.0f;
[SerializeField] private float openDuration = 1.5f; [SerializeField] private float openDuration = 1.5f;
[SerializeField] private float closeDuration = 0.18f; [SerializeField] private float closeDuration = 0.2f;
[Header("Motion")] [Header("Motion")]
[SerializeField] private AnimationCurve openCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); [SerializeField] private AnimationCurve openCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
@@ -32,15 +33,20 @@ public class ClamOpenClose : MonoBehaviour
[SerializeField] private bool useSnapClose = true; [SerializeField] private bool useSnapClose = true;
[Tooltip("닫힐 때 살짝 더 닫히는 오버슈트 각도입니다.")] [Tooltip("닫힐 때 살짝 더 닫히는 오버슈트 각도입니다.")]
[SerializeField] private float snapCloseOvershootX = 5f; [SerializeField] private float snapCloseOvershootX = 3f;
[Tooltip("쾅 하고 닫힌 뒤 원래 닫힌 위치로 돌아오는 시간입니다.")] [Tooltip("쾅 하고 닫힌 뒤 원래 닫힌 위치로 돌아오는 시간입니다.")]
[SerializeField] private float snapCloseOvershootDuration = 0.06f; [SerializeField] private float snapCloseOvershootDuration = 0.02f;
[Header("Start Option")] [Header("Start Option")]
[SerializeField] private bool startAutomatically = true; [SerializeField] private bool startAutomatically = true;
[SerializeField] private bool startOpened = false; [SerializeField] private bool startOpened = false;
[Header("Events")]
public UnityEvent onOpened;
public UnityEvent onCloseStarted;
public UnityEvent onClosed;
private Quaternion upClosedRot; private Quaternion upClosedRot;
private Quaternion downClosedRot; private Quaternion downClosedRot;
@@ -49,8 +55,10 @@ public class ClamOpenClose : MonoBehaviour
private Coroutine routine; private Coroutine routine;
private bool isOpen; private bool isOpen;
private bool isClosing;
public bool IsOpen => isOpen; public bool IsOpen => isOpen;
public bool IsClosing => isClosing;
private void Awake() private void Awake()
{ {
@@ -72,13 +80,11 @@ private void Awake()
return; return;
} }
// 현재 Scene에서 맞춰둔 로컬 회전을 닫힌 상태로 저장
upClosedRot = upShell.localRotation; upClosedRot = upShell.localRotation;
if (downShell != null) if (downShell != null)
downClosedRot = downShell.localRotation; downClosedRot = downShell.localRotation;
// 닫힌 상태 기준으로 X축 회전 추가
upOpenedRot = upClosedRot * Quaternion.Euler(upShellOpenX, 0f, 0f); upOpenedRot = upClosedRot * Quaternion.Euler(upShellOpenX, 0f, 0f);
if (downShell != null) if (downShell != null)
@@ -120,7 +126,8 @@ private IEnumerator OpenCloseRoutine()
float closedWait = Random.Range(minClosedTime, maxClosedTime); float closedWait = Random.Range(minClosedTime, maxClosedTime);
yield return new WaitForSeconds(closedWait); yield return new WaitForSeconds(closedWait);
// 천천히 열기 isClosing = false;
yield return MoveShells( yield return MoveShells(
upClosedRot, upClosedRot,
upOpenedRot, upOpenedRot,
@@ -131,11 +138,15 @@ private IEnumerator OpenCloseRoutine()
); );
isOpen = true; isOpen = true;
isClosing = false;
onOpened?.Invoke();
float openWait = Random.Range(minOpenTime, maxOpenTime); float openWait = Random.Range(minOpenTime, maxOpenTime);
yield return new WaitForSeconds(openWait); yield return new WaitForSeconds(openWait);
// 빠르게 닫기 isClosing = true;
onCloseStarted?.Invoke();
yield return MoveShells( yield return MoveShells(
upOpenedRot, upOpenedRot,
upClosedRot, upClosedRot,
@@ -147,11 +158,13 @@ private IEnumerator OpenCloseRoutine()
isOpen = false; isOpen = false;
// 쾅 닫히는 느낌
if (useSnapClose) if (useSnapClose)
{ {
yield return SnapCloseEffect(); yield return SnapCloseEffect();
} }
isClosing = false;
onClosed?.Invoke();
} }
} }
@@ -197,7 +210,6 @@ private IEnumerator SnapCloseEffect()
if (downShell != null) if (downShell != null)
downSnapRot = downClosedRot * Quaternion.Euler(-snapCloseOvershootX * 0.3f, 0f, 0f); downSnapRot = downClosedRot * Quaternion.Euler(-snapCloseOvershootX * 0.3f, 0f, 0f);
// 살짝 더 닫힘
if (upShell != null) if (upShell != null)
upShell.localRotation = upSnapRot; upShell.localRotation = upSnapRot;
@@ -206,7 +218,6 @@ private IEnumerator SnapCloseEffect()
yield return new WaitForSeconds(snapCloseOvershootDuration); yield return new WaitForSeconds(snapCloseOvershootDuration);
// 원래 닫힌 상태로 복귀
if (upShell != null) if (upShell != null)
upShell.localRotation = upClosedRot; upShell.localRotation = upClosedRot;
@@ -223,6 +234,7 @@ private void SetClosedImmediately()
downShell.localRotation = downClosedRot; downShell.localRotation = downClosedRot;
isOpen = false; isOpen = false;
isClosing = false;
} }
private void SetOpenedImmediately() private void SetOpenedImmediately()
@@ -234,5 +246,6 @@ private void SetOpenedImmediately()
downShell.localRotation = downOpenedRot; downShell.localRotation = downOpenedRot;
isOpen = true; isOpen = true;
isClosing = false;
} }
} }

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 310877c91d22e1142b0ea9977b24d3dc

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7c8bb23fa35921f41b4e864bd95c0c77

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4d64021aa9910eb4bb87691a2cdc6697

View 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();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a21b5472b91813b44bf392e78726081a

View 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}");
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 33f1269210c01984da23f85d35082e80

View File

@@ -16,6 +16,10 @@ public class RaftRiverController : MonoBehaviour
[SerializeField] private float forwardSpeed = 5f; [SerializeField] private float forwardSpeed = 5f;
[SerializeField] private float turnSpeed = 4f; [SerializeField] private float turnSpeed = 4f;
[Header("Start Speed Control")]
[Tooltip("0이면 정지, 1이면 정상 속도입니다. 시작 가속용으로 사용합니다.")]
[SerializeField] private float speedMultiplier = 1f;
[Header("Side Control")] [Header("Side Control")]
[SerializeField] private float sideMoveSpeed = 16f; [SerializeField] private float sideMoveSpeed = 16f;
[SerializeField] private float sideAcceleration = 40f; [SerializeField] private float sideAcceleration = 40f;
@@ -25,8 +29,10 @@ public class RaftRiverController : MonoBehaviour
[Header("Path Follow Feel")] [Header("Path Follow Feel")]
[SerializeField] private float pathFollowSmoothTime = 0.28f; [SerializeField] private float pathFollowSmoothTime = 0.28f;
[Range(0f, 1f)] [Range(0f, 1f)]
[SerializeField] private float rotationVelocityBlend = 0.45f; [SerializeField] private float rotationVelocityBlend = 0.45f;
[SerializeField] private float steeringYawAngle = 18f; [SerializeField] private float steeringYawAngle = 18f;
[Header("Manual Steering")] [Header("Manual Steering")]
@@ -38,10 +44,27 @@ public class RaftRiverController : MonoBehaviour
[SerializeField] private float arrivalSlowDownDistance = 12f; [SerializeField] private float arrivalSlowDownDistance = 12f;
[SerializeField] private float arrivalMinSpeed = 0.8f; [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")] [Header("Events")]
public UnityEvent onArrived; public UnityEvent onArrived;
private int currentPointIndex = 0; private int currentPointIndex = 0;
private float sideOffset = 0f; private float sideOffset = 0f;
private float sideVelocity = 0f; private float sideVelocity = 0f;
private float currentSteeringInput = 0f; private float currentSteeringInput = 0f;
@@ -53,6 +76,9 @@ public class RaftRiverController : MonoBehaviour
private Vector3 previousPosition; private Vector3 previousPosition;
private Vector3 startCenterPosition; private Vector3 startCenterPosition;
private Vector3 previousFinalDelta;
private bool hasPreviousFinalDelta;
private bool isFinished = false; private bool isFinished = false;
private bool warnedMissingSteeringKey; private bool warnedMissingSteeringKey;
@@ -76,10 +102,21 @@ private void Start()
currentCenterPosition = transform.position; currentCenterPosition = transform.position;
startCenterPosition = currentCenterPosition; startCenterPosition = currentCenterPosition;
currentForward = transform.forward; 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; previousPosition = transform.position;
currentPointIndex = 0; currentPointIndex = 0;
ResetFinalStopGuard();
} }
private void Update() private void Update()
@@ -88,24 +125,35 @@ private void Update()
return; return;
HandleSideControl(); HandleSideControl();
MoveAlongPath();
bool arrived = MoveAlongPath();
if (arrived)
return;
ApplyRaftPositionAndRotation(); ApplyRaftPositionAndRotation();
} }
private void MoveAlongPath() private bool MoveAlongPath()
{ {
SkipMissingPathPoints(); SkipMissingPathPoints();
int lastPointIndex = GetLastValidPathPointIndex(); int lastPointIndex = GetLastValidPathPointIndex();
if (lastPointIndex < 0 || currentPointIndex >= pathPoints.Length) if (lastPointIndex < 0 || currentPointIndex >= pathPoints.Length)
{ {
FinishRaftRide(); FinishRaftRide();
return; return true;
} }
Transform targetPoint = pathPoints[currentPointIndex]; Transform targetPoint = pathPoints[currentPointIndex];
if (targetPoint == null)
return false;
Vector3 toTarget = GetFlatVectorTo(targetPoint.position); Vector3 toTarget = GetFlatVectorTo(targetPoint.position);
float distance = toTarget.magnitude; float distance = toTarget.magnitude;
bool isLastTarget = currentPointIndex == lastPointIndex; bool isLastTarget = currentPointIndex == lastPointIndex;
while (!isLastTarget && ShouldAdvancePathPoint(currentPointIndex, distance)) while (!isLastTarget && ShouldAdvancePathPoint(currentPointIndex, distance))
@@ -115,46 +163,56 @@ private void MoveAlongPath()
if (currentPointIndex >= pathPoints.Length) if (currentPointIndex >= pathPoints.Length)
{ {
SnapToPathPoint(targetPoint); FinishAtFinalPoint();
FinishRaftRide(); return true;
return;
} }
targetPoint = pathPoints[currentPointIndex]; targetPoint = pathPoints[currentPointIndex];
if (targetPoint == null)
return false;
toTarget = GetFlatVectorTo(targetPoint.position); toTarget = GetFlatVectorTo(targetPoint.position);
distance = toTarget.magnitude; distance = toTarget.magnitude;
isLastTarget = currentPointIndex == lastPointIndex; isLastTarget = currentPointIndex == lastPointIndex;
} }
if (isLastTarget && distance <= pointReachDistance) if (isLastTarget && IsCloseEnoughToFinalPoint(distance))
{ {
SnapToPathPoint(targetPoint); FinishAtFinalPoint();
FinishRaftRide(); return true;
return;
} }
if (toTarget.sqrMagnitude < 0.001f) if (toTarget.sqrMagnitude < 0.001f)
return; return false;
Vector3 pathForward = toTarget.normalized; Vector3 pathForward = toTarget.normalized;
currentForward = GetTravelForward(pathForward); currentForward = GetTravelForward(pathForward);
float currentSpeed = GetCurrentForwardSpeed(distance); float currentSpeed = GetCurrentForwardSpeed(distance);
float moveDistance = currentSpeed * Time.deltaTime; float moveDistance = currentSpeed * Time.deltaTime;
if (isLastTarget && moveDistance >= distance) if (isLastTarget && moveDistance >= distance)
{ {
SnapToPathPoint(targetPoint); FinishAtFinalPoint();
FinishRaftRide(); return true;
return;
} }
currentCenterPosition += currentForward * moveDistance; 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() private void HandleSideControl()
{ {
float input = 0f; float input = 0f;
ResolveSteeringKey(); ResolveSteeringKey();
if (steeringKey != null) if (steeringKey != null)
@@ -178,6 +236,7 @@ private void HandleSideControl()
currentSteeringInput = input; currentSteeringInput = input;
float targetSideVelocity = input * sideMoveSpeed; float targetSideVelocity = input * sideMoveSpeed;
sideVelocity = Mathf.MoveTowards( sideVelocity = Mathf.MoveTowards(
sideVelocity, sideVelocity,
targetSideVelocity, targetSideVelocity,
@@ -202,6 +261,7 @@ private void ApplyRaftPositionAndRotation()
targetPosition.y = transform.position.y; targetPosition.y = transform.position.y;
float smoothTime = Mathf.Max(0.01f, pathFollowSmoothTime); float smoothTime = Mathf.Max(0.01f, pathFollowSmoothTime);
transform.position = Vector3.SmoothDamp( transform.position = Vector3.SmoothDamp(
transform.position, transform.position,
targetPosition, targetPosition,
@@ -215,6 +275,7 @@ private void ApplyRaftPositionAndRotation()
frameVelocity.y = 0f; frameVelocity.y = 0f;
Vector3 lookDirection = currentForward; Vector3 lookDirection = currentForward;
if (frameVelocity.sqrMagnitude > 0.0001f) if (frameVelocity.sqrMagnitude > 0.0001f)
{ {
lookDirection = Vector3.Slerp( lookDirection = Vector3.Slerp(
@@ -224,6 +285,9 @@ private void ApplyRaftPositionAndRotation()
); );
} }
if (lookDirection.sqrMagnitude < 0.001f)
return;
Quaternion targetRotation = Quaternion targetRotation =
Quaternion.LookRotation(lookDirection, Vector3.up) * Quaternion.LookRotation(lookDirection, Vector3.up) *
Quaternion.Euler(0f, currentSteeringInput * steeringYawAngle, 0f); 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() private void FinishRaftRide()
{ {
if (isFinished) if (isFinished)
return; return;
isFinished = true; isFinished = true;
SetSpeedMultiplier(0f);
sideVelocity = 0f;
currentSteeringInput = 0f;
positionSmoothVelocity = Vector3.zero;
Debug.Log("[RaftRiverController] Arrived at destination. Raft stopped."); Debug.Log("[RaftRiverController] Arrived at destination. Raft stopped.");
onArrived?.Invoke(); onArrived?.Invoke();
} }
public void StopRaft() public void StopRaft()
{ {
isFinished = true; isFinished = true;
sideVelocity = 0f;
currentSteeringInput = 0f;
positionSmoothVelocity = Vector3.zero;
} }
public void ResumeRaft() public void ResumeRaft()
{ {
isFinished = false; isFinished = false;
ResetFinalStopGuard();
}
public void SetSpeedMultiplier(float value)
{
speedMultiplier = Mathf.Clamp01(value);
} }
public void SetSteeringKey(SteeringKeyXR newSteeringKey) public void SetSteeringKey(SteeringKeyXR newSteeringKey)
@@ -286,7 +504,11 @@ private Vector3 GetTravelForward(Vector3 fallbackForward)
if (steeringKey != null && steeringKey.IsGrabbed) 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; travelForward = Quaternion.Euler(0f, turnAmount, 0f) * travelForward.normalized;
} }
@@ -307,15 +529,22 @@ private bool ShouldAdvancePathPoint(int targetIndex, float distanceToTarget)
private bool HasPassedPathPoint(int targetIndex) 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; return true;
}
Vector3 anchorPosition = GetPreviousPathAnchorPosition(targetIndex); Vector3 anchorPosition = GetPreviousPathAnchorPosition(targetIndex);
Vector3 targetPosition = pathPoints[targetIndex].position; Vector3 targetPosition = pathPoints[targetIndex].position;
anchorPosition.y = currentCenterPosition.y; anchorPosition.y = currentCenterPosition.y;
targetPosition.y = currentCenterPosition.y; targetPosition.y = currentCenterPosition.y;
Vector3 segment = targetPosition - anchorPosition; Vector3 segment = targetPosition - anchorPosition;
if (segment.sqrMagnitude < 0.001f) if (segment.sqrMagnitude < 0.001f)
return false; return false;
@@ -348,12 +577,16 @@ private void SnapToPathPoint(Transform point)
finalPosition.y = transform.position.y; finalPosition.y = transform.position.y;
currentCenterPosition = finalPosition; currentCenterPosition = finalPosition;
sideOffset = 0f; sideOffset = 0f;
sideVelocity = 0f; sideVelocity = 0f;
currentSteeringInput = 0f; currentSteeringInput = 0f;
positionSmoothVelocity = Vector3.zero; positionSmoothVelocity = Vector3.zero;
previousPosition = finalPosition; previousPosition = finalPosition;
transform.position = finalPosition; transform.position = finalPosition;
ResetFinalStopGuard();
} }
private int GetPenultimateValidPathPointIndex() private int GetPenultimateValidPathPointIndex()
@@ -383,18 +616,25 @@ private int GetPenultimateValidPathPointIndex()
private float GetArrivalSlowDownDistance() private float GetArrivalSlowDownDistance()
{ {
float fallbackDistance = Mathf.Max(pointReachDistance + 0.01f, arrivalSlowDownDistance); float fallbackDistance = Mathf.Max(pointReachDistance + 0.01f, arrivalSlowDownDistance);
int penultimateIndex = GetPenultimateValidPathPointIndex(); int penultimateIndex = GetPenultimateValidPathPointIndex();
int lastPointIndex = GetLastValidPathPointIndex(); int lastPointIndex = GetLastValidPathPointIndex();
if (penultimateIndex < 0 || lastPointIndex < 0 || penultimateIndex == lastPointIndex) if (penultimateIndex < 0 ||
lastPointIndex < 0 ||
penultimateIndex == lastPointIndex)
{
return fallbackDistance; return fallbackDistance;
}
Vector3 penultimatePosition = pathPoints[penultimateIndex].position; Vector3 penultimatePosition = pathPoints[penultimateIndex].position;
Vector3 finalPosition = pathPoints[lastPointIndex].position; Vector3 finalPosition = pathPoints[lastPointIndex].position;
penultimatePosition.y = 0f; penultimatePosition.y = 0f;
finalPosition.y = 0f; finalPosition.y = 0f;
float finalSegmentDistance = Vector3.Distance(penultimatePosition, finalPosition); float finalSegmentDistance = Vector3.Distance(penultimatePosition, finalPosition);
if (finalSegmentDistance <= pointReachDistance) if (finalSegmentDistance <= pointReachDistance)
return fallbackDistance; return fallbackDistance;
@@ -403,22 +643,46 @@ private float GetArrivalSlowDownDistance()
private float GetCurrentForwardSpeed(float distanceToTarget) private float GetCurrentForwardSpeed(float distanceToTarget)
{ {
float baseSpeed;
if (currentPointIndex != GetLastValidPathPointIndex()) if (currentPointIndex != GetLastValidPathPointIndex())
return forwardSpeed; {
baseSpeed = forwardSpeed;
}
else
{
float maxSpeed = Mathf.Max(0f, forwardSpeed);
float maxSpeed = Mathf.Max(0f, forwardSpeed); if (maxSpeed <= 0.01f)
if (maxSpeed <= 0.01f) {
return maxSpeed; baseSpeed = maxSpeed;
}
else
{
float slowDownDistance = GetArrivalSlowDownDistance();
float slowDownDistance = GetArrivalSlowDownDistance(); if (slowDownDistance <= pointReachDistance)
if (slowDownDistance <= pointReachDistance) {
return maxSpeed; baseSpeed = maxSpeed;
}
else
{
float minSpeed = Mathf.Clamp(arrivalMinSpeed, 0.01f, maxSpeed);
float minSpeed = Mathf.Clamp(arrivalMinSpeed, 0.01f, maxSpeed); float speedRatio = Mathf.InverseLerp(
float speedRatio = Mathf.InverseLerp(pointReachDistance, slowDownDistance, distanceToTarget); pointReachDistance,
speedRatio = speedRatio * speedRatio * (3f - 2f * speedRatio); 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() private int GetLastValidPathPointIndex()
@@ -443,6 +707,12 @@ private void SkipMissingPathPoints()
} }
} }
private void ResetFinalStopGuard()
{
previousFinalDelta = Vector3.zero;
hasPreviousFinalDelta = false;
}
private float ReadLegacyHorizontalInput() private float ReadLegacyHorizontalInput()
{ {
#if ENABLE_LEGACY_INPUT_MANAGER #if ENABLE_LEGACY_INPUT_MANAGER

View 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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 24e1027c38bc1e34b9b4d80397ad481a

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1650344929a31bf469215ce025b8fd1d

View File

@@ -0,0 +1,5 @@
using UnityEngine;
public class XRHandMarker : MonoBehaviour
{
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 559f6e10a7fe4e2468ff96e9693b444e

View File

@@ -7,11 +7,11 @@ AnimatorState:
m_CorrespondingSourceObject: {fileID: 0} m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_Name: WhiteRhino_Skelmesh|Rhino_Combat_Atk_Hit m_Name: Hit
m_Speed: 1 m_Speed: 1
m_CycleOffset: 0 m_CycleOffset: 0
m_Transitions: m_Transitions:
- {fileID: 8273947539310789631} - {fileID: 5211168254593058196}
m_StateMachineBehaviours: [] m_StateMachineBehaviours: []
m_Position: {x: 50, y: 50, z: 0} m_Position: {x: 50, y: 50, z: 0}
m_IKOnFeet: 0 m_IKOnFeet: 0
@@ -36,7 +36,7 @@ AnimatorStateTransition:
m_Name: m_Name:
m_Conditions: m_Conditions:
- m_ConditionMode: 1 - m_ConditionMode: 1
m_ConditionEvent: isIdle m_ConditionEvent: Hit
m_EventTreshold: 0 m_EventTreshold: 0
m_DstStateMachine: {fileID: 0} m_DstStateMachine: {fileID: 0}
m_DstState: {fileID: -5762510348686555522} m_DstState: {fileID: -5762510348686555522}
@@ -47,7 +47,7 @@ AnimatorStateTransition:
m_TransitionDuration: 0.25 m_TransitionDuration: 0.25
m_TransitionOffset: 0 m_TransitionOffset: 0
m_ExitTime: 0.95 m_ExitTime: 0.95
m_HasExitTime: 1 m_HasExitTime: 0
m_HasFixedDuration: 1 m_HasFixedDuration: 1
m_InterruptionSource: 0 m_InterruptionSource: 0
m_OrderedInterruption: 1 m_OrderedInterruption: 1
@@ -63,7 +63,7 @@ AnimatorStateMachine:
m_ChildStates: m_ChildStates:
- serializedVersion: 1 - serializedVersion: 1
m_State: {fileID: 2482721225991305447} m_State: {fileID: 2482721225991305447}
m_Position: {x: 330, y: 310, z: 0} m_Position: {x: 300, y: 250, z: 0}
- serializedVersion: 1 - serializedVersion: 1
m_State: {fileID: -5762510348686555522} m_State: {fileID: -5762510348686555522}
m_Position: {x: 670, y: 290, z: 0} m_Position: {x: 670, y: 290, z: 0}
@@ -86,8 +86,8 @@ AnimatorController:
m_Name: Rhinos m_Name: Rhinos
serializedVersion: 5 serializedVersion: 5
m_AnimatorParameters: m_AnimatorParameters:
- m_Name: isIdle - m_Name: Hit
m_Type: 4 m_Type: 9
m_DefaultFloat: 0 m_DefaultFloat: 0
m_DefaultInt: 0 m_DefaultInt: 0
m_DefaultBool: 0 m_DefaultBool: 0
@@ -112,7 +112,7 @@ AnimatorState:
m_CorrespondingSourceObject: {fileID: 0} m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0} m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0} m_PrefabAsset: {fileID: 0}
m_Name: WhiteRhino_Skelmesh|AA_WhiteRhino_Loco_Idle_Normal m_Name: Idle
m_Speed: 1 m_Speed: 1
m_CycleOffset: 0 m_CycleOffset: 0
m_Transitions: m_Transitions:
@@ -132,7 +132,7 @@ AnimatorState:
m_MirrorParameter: m_MirrorParameter:
m_CycleOffsetParameter: m_CycleOffsetParameter:
m_TimeParameter: m_TimeParameter:
--- !u!1101 &8273947539310789631 --- !u!1101 &5211168254593058196
AnimatorStateTransition: AnimatorStateTransition:
m_ObjectHideFlags: 1 m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0} m_CorrespondingSourceObject: {fileID: 0}
@@ -141,7 +141,7 @@ AnimatorStateTransition:
m_Name: m_Name:
m_Conditions: [] m_Conditions: []
m_DstStateMachine: {fileID: 0} m_DstStateMachine: {fileID: 0}
m_DstState: {fileID: -5762510348686555522} m_DstState: {fileID: 2482721225991305447}
m_Solo: 0 m_Solo: 0
m_Mute: 0 m_Mute: 0
m_IsExit: 0 m_IsExit: 0

Binary file not shown.