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, 효과음을 각각 볼륨 조절하거나 음소거 기능 구현 가능.
- 나중에 오브젝트 풀링이나 볼륨 페이드 등 기능을 확장하기도 편함.
싱글톤 구조
- 어디서든 접근 가능하므로 구조가 단순해지고 코드 일관성이 높아짐.