338 lines
9.6 KiB
C#
338 lines
9.6 KiB
C#
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<AudioSource>();
|
|
}
|
|
|
|
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;
|
|
}
|
|
} |