using System.Collections; using UnityEngine; using UnityEngine.Events; public class ClamOpenClose : MonoBehaviour { [Header("Shell Hinges")] [SerializeField] private Transform upShell; [SerializeField] private Transform downShell; [Header("Open Angle")] [Tooltip("X축 기준으로 얼마나 열릴지 설정합니다. 음수값이 커질수록 더 많이 열립니다.")] [SerializeField] private float upShellOpenX = -45f; [Tooltip("아래 조개도 함께 움직일 경우 사용합니다. 필요 없으면 0으로 두세요.")] [SerializeField] private float downShellOpenX = 0f; [Header("Timing")] [SerializeField] private float minClosedTime = 0.6f; [SerializeField] private float maxClosedTime = 1.8f; [SerializeField] private float minOpenTime = 0.8f; [SerializeField] private float maxOpenTime = 2.0f; [SerializeField] private float openDuration = 1.5f; [SerializeField] private float closeDuration = 0.2f; [Header("Motion")] [SerializeField] private AnimationCurve openCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); [SerializeField] private AnimationCurve closeCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); [Header("Snap Close Effect")] [SerializeField] private bool useSnapClose = true; [Tooltip("닫힐 때 살짝 더 닫히는 오버슈트 각도입니다.")] [SerializeField] private float snapCloseOvershootX = 3f; [Tooltip("쾅 하고 닫힌 뒤 원래 닫힌 위치로 돌아오는 시간입니다.")] [SerializeField] private float snapCloseOvershootDuration = 0.02f; [Header("Sound")] [SerializeField] private AudioSource audioSource; [Tooltip("조개가 닫히며 입이 부딪힐 때 재생할 사운드입니다. 여러 개를 넣으면 랜덤 재생됩니다.")] [SerializeField] private AudioClip[] closeImpactSounds; [Tooltip("조개가 닫히기 시작할 때 재생할 예고/마찰 사운드입니다. 필요 없으면 비워두세요.")] [SerializeField] private AudioClip closeStartSound; [Range(0f, 1f)] [SerializeField] private float closeImpactVolume = 1f; [Range(0f, 1f)] [SerializeField] private float closeStartVolume = 0.5f; [Tooltip("닫힘 충격음의 최소 피치입니다.")] [SerializeField] private float minImpactPitch = 0.9f; [Tooltip("닫힘 충격음의 최대 피치입니다.")] [SerializeField] private float maxImpactPitch = 1.1f; [Tooltip("닫힘 충격음의 볼륨을 살짝 랜덤하게 줄지 여부입니다.")] [SerializeField] private bool randomizeImpactVolume = true; [Tooltip("볼륨 랜덤 적용 시 최소 배율입니다.")] [SerializeField] private float minImpactVolumeMultiplier = 0.85f; [Tooltip("볼륨 랜덤 적용 시 최대 배율입니다.")] [SerializeField] private float maxImpactVolumeMultiplier = 1.0f; [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; private Quaternion upOpenedRot; private Quaternion downOpenedRot; private Coroutine routine; private bool isOpen; private bool isClosing; public bool IsOpen => isOpen; public bool IsClosing => isClosing; private void Awake() { if (upShell == null) { Transform found = transform.Find("Scale/UpShell"); if (found != null) upShell = found; } if (downShell == null) { Transform found = transform.Find("Scale/DownShell"); if (found != null) downShell = found; } if (audioSource == null) { audioSource = GetComponent(); } if (audioSource != null) { audioSource.playOnAwake = false; audioSource.loop = false; } if (upShell == null) { Debug.LogWarning("[ClamOpenClose] UpShell이 연결되지 않았습니다.", this); return; } upClosedRot = upShell.localRotation; if (downShell != null) downClosedRot = downShell.localRotation; upOpenedRot = upClosedRot * Quaternion.Euler(upShellOpenX, 0f, 0f); if (downShell != null) downOpenedRot = downClosedRot * Quaternion.Euler(downShellOpenX, 0f, 0f); } private void Start() { if (startOpened) SetOpenedImmediately(); else SetClosedImmediately(); if (startAutomatically) StartClam(); } public void StartClam() { if (routine != null) StopCoroutine(routine); routine = StartCoroutine(OpenCloseRoutine()); } public void StopClam() { if (routine != null) { StopCoroutine(routine); routine = null; } } private IEnumerator OpenCloseRoutine() { while (true) { float closedWait = Random.Range(minClosedTime, maxClosedTime); yield return new WaitForSeconds(closedWait); isClosing = false; yield return MoveShells( upClosedRot, upOpenedRot, downClosedRot, downOpenedRot, openDuration, openCurve ); isOpen = true; isClosing = false; onOpened?.Invoke(); float openWait = Random.Range(minOpenTime, maxOpenTime); yield return new WaitForSeconds(openWait); isClosing = true; PlayCloseStartSound(); onCloseStarted?.Invoke(); yield return MoveShells( upOpenedRot, upClosedRot, downOpenedRot, downClosedRot, closeDuration, closeCurve ); isOpen = false; // 실제 입이 맞닿는 타이밍. // 랜덤으로 닫혀도 항상 닫힘 동작이 끝난 직후 실행됨. PlayCloseImpactSound(); if (useSnapClose) { yield return SnapCloseEffect(); } isClosing = false; onClosed?.Invoke(); } } private IEnumerator MoveShells( Quaternion upFrom, Quaternion upTo, Quaternion downFrom, Quaternion downTo, float duration, AnimationCurve curve) { float timer = 0f; while (timer < duration) { timer += Time.deltaTime; float t = Mathf.Clamp01(timer / duration); float curvedT = curve.Evaluate(t); if (upShell != null) upShell.localRotation = Quaternion.Slerp(upFrom, upTo, curvedT); if (downShell != null) downShell.localRotation = Quaternion.Slerp(downFrom, downTo, curvedT); yield return null; } if (upShell != null) upShell.localRotation = upTo; if (downShell != null) downShell.localRotation = downTo; } private IEnumerator SnapCloseEffect() { Quaternion upSnapRot = upClosedRot * Quaternion.Euler(snapCloseOvershootX, 0f, 0f); Quaternion downSnapRot = downClosedRot; if (downShell != null) downSnapRot = downClosedRot * Quaternion.Euler(-snapCloseOvershootX * 0.3f, 0f, 0f); if (upShell != null) upShell.localRotation = upSnapRot; if (downShell != null) downShell.localRotation = downSnapRot; yield return new WaitForSeconds(snapCloseOvershootDuration); if (upShell != null) upShell.localRotation = upClosedRot; if (downShell != null) downShell.localRotation = downClosedRot; } private void PlayCloseStartSound() { if (audioSource == null || closeStartSound == null) return; audioSource.pitch = 1f; audioSource.PlayOneShot(closeStartSound, closeStartVolume); } private void PlayCloseImpactSound() { if (audioSource == null) return; if (closeImpactSounds == null || closeImpactSounds.Length == 0) return; AudioClip clip = closeImpactSounds[Random.Range(0, closeImpactSounds.Length)]; if (clip == null) return; float originalPitch = audioSource.pitch; audioSource.pitch = Random.Range(minImpactPitch, maxImpactPitch); float volume = closeImpactVolume; if (randomizeImpactVolume) { float volumeMultiplier = Random.Range(minImpactVolumeMultiplier, maxImpactVolumeMultiplier); volume *= volumeMultiplier; } audioSource.PlayOneShot(clip, volume); // PlayOneShot은 재생 직후 피치를 바꿔도 이미 재생 중인 소리에 영향이 적지만, // 다음 사운드 재생을 위해 기본 피치로 복구. audioSource.pitch = originalPitch; } private void SetClosedImmediately() { if (upShell != null) upShell.localRotation = upClosedRot; if (downShell != null) downShell.localRotation = downClosedRot; isOpen = false; isClosing = false; } private void SetOpenedImmediately() { if (upShell != null) upShell.localRotation = upOpenedRot; if (downShell != null) downShell.localRotation = downOpenedRot; isOpen = true; isClosing = false; } }