Unity 최종

Unity 총기 흔들림, 반동 구현, 사운드 처리

1vdlrwnsv1 2025. 4. 9. 20:55

총기의 모든 수치들은 WeaponData.json으로 관리

1. 총기 흔들림 (Weapon Shake)

  • 의도: 무기의 정확도(accuracy)가 낮을수록 총이 손에서 더 많이 흔들리는 듯한 느낌을 주기 위함.
  • 구현 방식: Mathf.PerlinNoise를 사용하여 부드러운 노이즈 기반의 회전값을 적용함.

핵심 코드

float rotX = (PerlinNoise) * shakeAmount * 0.7f;
float rotY = (PerlinNoise) * shakeAmount * 3.5f;
float rotZ = (PerlinNoise) * shakeAmount;

Quaternion shakeRotation = Quaternion.Euler(rotX, rotY, rotZ);
gunTransform.localRotation = initialLocalRotation * shakeRotation;

y값에 더 높은 값을 곱한 이유는 상하로는 덜 흔들리고 좌우로 더 흔들리게 만들기 위함.

정확도 수치 계산에서 Mathf.Clamp01으로 0~1 사이의 비율을 안정적으로 유지.

 

2. 총기 반동 (Gun Recoil)

  • 의도: 발사 시 카메라가 위로 튀는 효과로 반동을 표현.
  • 구현 방식: 카메라의 localRotation에 pitch(상하 회전)를 추가.
  • 핵심 코드:
playerCam.transform.localRotation *= Quaternion.Euler(-weaponData.shootRecoil * 0.01f, 0f, 0f);

shootRecoil 값에 비례해 카메라가 쏠때마다 위로 올라감.

 

 

  • 카메라 흔들림 (Camera Shake): 발사 후 일시적인 화면 흔들림 효과 추가.
  • 머즐 플래시 / 탄피 배출 / 사운드 이펙트: 몰입감을 위한 부가 효과도 같이 처리함.

 

 

WeaponStatHandler.cs

using System.Collections;
using UnityEngine;

public class WeaponStatHandler : MonoBehaviour
{
    public WeaponData weaponData;
    public Animator gunAnimator;
    public GameObject casingPrefab;
    public GameObject muzzleFlashPrefab;
    public GameObject bulletImpactPrefab;
    public GameObject playerCam;
    public bool isReloading;

    [SerializeField] private Transform barrelLocation;
    [SerializeField] private Transform casingExitLocation;

    [Header("Settings")]
    [Tooltip("Specify time to destroy the casing object")]
    [SerializeField] private float destroyTimer = 2f;
    [Tooltip("Casing Ejection Speed")]
    [SerializeField] private float ejectPower = 150f;

    private float fireCooldown = 0.7f;
    private float lastFireTime = 0f;

    [SerializeField] private Transform gunTransform;
    private Quaternion initialLocalRotation;
    void Start()
    {
        LoadWeaponData();

        if (barrelLocation == null)
        {
            barrelLocation = transform;
        }

        if (gunAnimator == null)
        {
            gunAnimator = GetComponentInChildren<Animator>();
        }

        initialLocalRotation = gunTransform.localRotation;
    }

    void Update()
    {
        WeaponShake();

        if (Input.GetButtonDown("Fire1") && Time.time - lastFireTime >= fireCooldown)
        {
            FireWeapon();
        }
        if (Input.GetKeyDown(KeyCode.R) && !isReloading)
        {
            ReloadWeapon();
        }
    }
    #region 발사
    public void FireWeapon()
    {
        if (weaponData != null)
        {
            if (weaponData.currentAmmo > 0)
            {
                if (gunAnimator != null && weaponData.currentAmmo != 1)
                {
                    gunAnimator.SetTrigger("Fire");

                }
                if (weaponData.currentAmmo == 1)
                {
                    gunAnimator.SetBool("OutOfAmmo", true);
                }

                // 레이 발사
                ShootRay();
                CasingRelease();
                MuzzleFlash();
                GunRecoil();
                SoundManager.Instance.PlaySFX("M1911Fire");

                weaponData.currentAmmo--;
                lastFireTime = Time.time;
            }else
            {
                SoundManager.Instance.PlaySFX("EmptyTrigger");
            }

        }
    }

    public void ShootRay()
    {
        Ray ray = new Ray(barrelLocation.position, barrelLocation.forward);
        RaycastHit hit;

        Debug.DrawRay(ray.origin, ray.direction * 100f, Color.red, 0f);


        if (Physics.Raycast(ray, out hit))
        {
            Debug.Log("Hit: " + hit.collider.name);

            if (bulletImpactPrefab)
            {
                //ToDO: 오브젝트 풀링으로 관리
                Quaternion hitRotation = Quaternion.LookRotation(hit.normal);
                GameObject impact = Instantiate(bulletImpactPrefab, hit.point, hitRotation);
                impact.transform.SetParent(hit.collider.transform);
                Destroy(impact, 2f);
            }
            if (hit.collider.gameObject.layer == LayerMask.NameToLayer("Target"))
            {
                Target target = hit.collider.GetComponentInParent<Target>();
                if (target != null)
                {
                    target.TakeDamage(weaponData.damage, hit.collider);
                }
            }
        }
        StartCoroutine(CameraShake(weaponData.cameraShakeRate * 0.005f));
    }

    private IEnumerator CameraShake(float intensity)
    {
        Vector3 originalPos = playerCam.transform.localPosition;

        float duration = 0.25f;
        float timer = 0f;

        while (timer < duration)
        {
            float damper = 1f - (timer / duration);

            float x = Random.Range(-1f, 1f) * intensity * damper;
            float y = Random.Range(-1f, 1f) * intensity * damper;

            playerCam.transform.localPosition = originalPos + new Vector3(x, y, 0f);

            timer += Time.deltaTime;
            yield return null;
        }

        playerCam.transform.localPosition = originalPos;
    }

    // 탄피 배출 처리
    void CasingRelease()
    {
        //ToDO: 오브젝트 풀링으로 관리
        if (casingExitLocation && casingPrefab)
        {
            GameObject tempCasing = Instantiate(casingPrefab, casingExitLocation.position, casingExitLocation.rotation);
            Rigidbody casingRb = tempCasing.GetComponent<Rigidbody>();

            if (casingRb != null)
            {
                casingRb.AddExplosionForce(Random.Range(ejectPower * 0.7f, ejectPower),
                                          (casingExitLocation.position - casingExitLocation.right * 0.3f - casingExitLocation.up * 0.6f), 1f);
                casingRb.AddTorque(new Vector3(0, Random.Range(100f, 500f), Random.Range(100f, 1000f)), ForceMode.Impulse);
            }
            SoundManager.Instance.PlaySFX("Shell");

            Destroy(tempCasing, destroyTimer);
        }
    }


    // 머즐 플래시 처리
    void MuzzleFlash()
    {
        //ToDO: 오브젝트 풀링으로 관리
        if (muzzleFlashPrefab)
        {
            GameObject tempFlash = Instantiate(muzzleFlashPrefab, barrelLocation.position, barrelLocation.rotation);
            tempFlash.transform.SetParent(barrelLocation.transform);
            Destroy(tempFlash, destroyTimer);
        }
    }
    #endregion

    #region 총기 흔들림
    //손떨림 
    //ToDo : 정조준시 흔들리게 바꿔야함
    private void WeaponShake()
    {
        if (weaponData == null || gunTransform == null)
        {
            return;
        }

        float accuracy = Mathf.Clamp01((99f - weaponData.accuracy) / 98f);
        float shakeAmount = accuracy * 7.5f;
        float shakeSpeed = 0.7f;

        float rotX = (Mathf.PerlinNoise(Time.time * shakeSpeed, 0f) - 0.5f) * shakeAmount * 1f;
        float rotY = (Mathf.PerlinNoise(0f, Time.time * shakeSpeed) - 0.5f) * shakeAmount * 3f;
        float rotZ = (Mathf.PerlinNoise(Time.time * shakeSpeed, Time.time * shakeSpeed) - 0.5f) * shakeAmount;

        Quaternion shakeRotation = Quaternion.Euler(rotX, rotY, rotZ);

        gunTransform.localRotation = initialLocalRotation * shakeRotation;
    }

    //반동
    private void GunRecoil()
    {
        playerCam.transform.localRotation *= Quaternion.Euler(-weaponData.shootRecoil * 0.01f, 0f, 0f);
    }
    #endregion

    // WeaponData 로드
    void LoadWeaponData()
    {
        TextAsset jsonData = Resources.Load<TextAsset>("Data/JSON/PistolData");

        if (jsonData != null)
        {
            weaponData = JsonUtility.FromJson<WeaponData>(jsonData.text);
        }
    }

    #region 재장전
    void ReloadWeapon()
    {
        if (weaponData != null && weaponData.currentAmmo != weaponData.maxAmmo)
        {
            isReloading = true;
            weaponData.currentAmmo = 0;
            gunAnimator.SetTrigger("Reload");
            SoundManager.Instance.PlaySFX("Reload");
            StartCoroutine(WaitForEndOfReload());

        }
    }

    private IEnumerator WaitForEndOfReload()
    {
        yield return new WaitForSeconds(weaponData.reloadTime);
        gunAnimator.SetBool("OutOfAmmo", false);
        weaponData.currentAmmo = weaponData.maxAmmo;
        isReloading = false;

    }
    #endregion
}

클래스 명을 WeaponHandler로 바꿔야할듯

 

사운드 처리 (SoundManager)

  • 의도: 발사음, 탄피 튕김음, 재장전 사운드를 상황에 맞게 재생. 빈 총 소리도 넣었는데 귀찮아서 안씀
  • 사용 예시:
SoundManager.Instance.PlaySFX("M1911Fire");
SoundManager.Instance.PlaySFX("Shell");
SoundManager.Instance.PlaySFX("Reload");

 

SoundManager.cs

using System;
using System.Collections.Generic;
using UnityEngine;

/// <summary>
/// 예시
/// 배경음악: SoundManager.Instance.PlayBackgroundMusic("사운드 이름");
/// 효과음: SoundManager.Instance.PlaySFX("사운드 이름");
/// </summary>

public class SoundManager : MonoBehaviour
{
    public static SoundManager Instance;
    private AudioSource musicSource;
    private AudioSource sfxSource;

    [Header("Settings")]
    [Tooltip("배경음악 볼륨")]
    [Range(0f, 1f)]
    public float backgroundMusicVol = 0.5f;
    [Tooltip("효과음 볼륨")]
    [Range(0f, 1f)]
    public float sfxVol = 0.5f;

    [Header("오디오 클립")]
    public AudioClip backgroundMusic;
    public Dictionary<String, AudioClip> soundEffects = new Dictionary<string, AudioClip>();



    private void Awake()
    {
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
            Destroy(gameObject);
        }

        // Ensure the music and sfx sources are assigned
        if (musicSource == null) musicSource = GetComponent<AudioSource>();  // 음악 소스
        if (sfxSource == null) sfxSource = gameObject.AddComponent<AudioSource>();  // 효과음 소스

        musicSource.volume = backgroundMusicVol;
        sfxSource.volume = sfxVol;
        musicSource.loop = true;
    }

    void Start()
    {
        PlayBackgroundMusic(backgroundMusic);
    }

    #region 배경음악 관련
    void PlayBackgroundMusic(AudioClip music)
    {
        if (music != null)
        {
            musicSource.clip = music;
            musicSource.Play();
        }
    }
    public void PauseBackgroundMusic()
    {
        //ToDo 음악멈추기
    }

    public void ResumeBackgroundMusic()
    {
        //ToDo 음악다시켜기
    }
    #endregion

    #region 효과음 관련
    /// <summary>
    /// 사운드 넣고 싶은거 넣으세요
    /// </summary>
    /// <param name="string"></param>
    /// <param name="AudioClip"></param>
    public void AddSoundEffect(string soundName, AudioClip clip)
    {
        if (!soundEffects.ContainsKey(soundName))
        {
            soundEffects.Add(soundName, clip);
        }
    }

    /// <summary>
    /// 사운드 효과 재생
    /// </summary>
    /// <param name="string"></param>
    /// 
    public void PlaySFX(string soundName)
    {
        if (soundEffects.TryGetValue(soundName, out AudioClip clip))
        {
            sfxSource.PlayOneShot(clip, sfxVol);
        }
        else
        {
            clip = Resources.Load<AudioClip>("Audio/SFX/" + soundName);

            if (clip != null)
            {
                soundEffects[soundName] = clip;
                sfxSource.PlayOneShot(clip, sfxVol);
            }
            else
            {
                Debug.Log("sound 못찾음 " + soundName);
            }
        }
    }
    /// <summary>
    /// 클립 직접 넘겨서 재생
    /// </summary>
    public void PlaySFX(AudioClip clip)
    {
        if (clip != null)
        {
            sfxSource.PlayOneShot(clip, sfxVol);
        }
    }
    #endregion

    #region 볼륨
    /// <summary>
    /// 효과음 볼륨
    /// </summary>
    /// <param name="float"></param>
    public void SetSFXVolume(float volume)
    {
        sfxVol = Mathf.Clamp(volume, 0f, 1f);
        sfxSource.volume = sfxVol;
    }

    /// <summary>
    /// 배경음악 볼륨
    /// </summary>
    /// <param name="volume"></param>
    public void SetMusicVolume(float volume)
    {
        backgroundMusicVol = Mathf.Clamp(volume, 0f, 1f);
        musicSource.volume = backgroundMusicVol;
    }
    #endregion
}

 

 

정리

1. 총기 흔들림 (Weapon Shake)

  • 총기 정확도(accuracy) 수치를 기반으로 손떨림 정도를 결정.
  • 정확도가 99일 때 가장 안정적이고, 낮을수록 총기 모델이 더 많이 흔들림.
  • Mathf.PerlinNoise를 사용해 부드럽고 랜덤한 회전값 생성.
  • gunTransform.localRotation에 반영하여 시각적 흔들림 효과를 줌.
  • 코드 내에서 정확도 계산 시 Mathf.Clamp01((99f - accuracy) / 98f)를 사용하여 0~1 사이 값으로 정규화.

2. 총기 반동 (Recoil)

  • 발사 시 카메라의 pitch (X축 회전)를 위로 회전시켜 반동 효과 구현.
  • playerCam.transform.localRotation에 Quaternion.Euler를 곱해 적용.
  • weaponData.shootRecoil 값이 클수록 반동도 커짐.
  • 간단하지만 직관적으로 반동을 느낄 수 있게 해줌.

 

SoundManager 사용 이유 및 장점

재사용성과 일관성

  • 하나의 SoundManager를 통해 게임 전역에서 사운드 재생.
  • 모든 사운드를 중앙에서 관리할 수 있어 유지보수가 쉬움.

코드 중복 최소화

  • 개별 오브젝트마다 AudioSource를 만들 필요 없이, 단일 진입점에서 사운드 재생 가능.
  • SoundManager.Instance.PlaySFX("Reload") 형태로 간단하게 사용 가능.

유지보수 용이

  • 사운드 파일을 바꾸거나 설정을 조정할 경우 SoundManager만 수정하면 됨.
  • 효과음 키("Reload", "Shot", "Hit" 등) 기반으로 접근하므로 의미 파악이 쉬움.

볼륨 조절 및 확장성

  • BGM, 효과음을 각각 볼륨 조절하거나 음소거 기능 구현 가능.
  • 나중에 오브젝트 풀링이나 볼륨 페이드 등 기능을 확장하기도 편함.

싱글톤 구조

  • 어디서든 접근 가능하므로 구조가 단순해지고 코드 일관성이 높아짐.