Files
2026-07-22 19:26:44 +09:00

145 lines
6.2 KiB
C#

using System;
using UnityEngine;
// 파티클이 새로 태어나는 순간(발사 / 폭발)에 사운드 하나, (옵션) 라이트 하나를 재생하는 최소 컴포넌트.
// - 발사 파티클(쏘아올리는 것)에는 사운드만
// - dead 이벤트 서브 이미터로 터지는 파티클에는 사운드 + 라이트
// Unity엔 "파티클 생성 콜백"이 없어서, 살아있는 파티클 수가 늘어나는 순간을 감지한다.
[RequireComponent(typeof(ParticleSystem))]
public class ParticleSfx : MonoBehaviour
{
[Header("사운드")]
[SerializeField] private AudioClip[] _sfx; // 여러 개면 무작위 (반복감 완화)
[SerializeField, Range(0f, 1f)] private float _volume = 0.8f;
[Tooltip("폭발마다 사운드가 들릴 확률 (1=항상). 폭죽이 많아 소리가 겹칠 때 낮춰서 솎아냄")]
[SerializeField, Range(0f, 1f)] private float _sfxChance = 1f;
[Tooltip("이 거리 안에선 풀 볼륨. 하늘 높이 터지는 폭발음은 크게(예: 30~50) 잡아야 잘 들림")]
[SerializeField] private float _minDistance = 30f;
[SerializeField] private float _maxDistance = 500f;
[Header("라이트 (폭발용, 옵션)")]
[SerializeField] private bool _flashLight = false;
[Tooltip("지정하면 라이트를 폭발 위치가 아니라 이 콜라이더 범위 안의 무작위 지점에 띄운다. 폭죽은 위·옆으로 " +
"제멋대로 퍼지므로, NPC를 비추려면 관객 구역에 콜라이더(예: Box, IsTrigger)를 두고 여기 연결. 비우면 폭발 위치.")]
[SerializeField] private Collider _lightArea;
[SerializeField] private Color _lightColor = new(1f, 0.9f, 0.7f); // 따뜻한 백색 (여러 색 폭발의 캐스트광은 섞여서 이게 자연스러움)
[SerializeField] private float _lightIntensity = 30f;
[SerializeField] private float _lightRange = 25f;
[SerializeField] private float _flashDuration = 0.35f;
[SerializeField] private LightShadows _shadows = LightShadows.None; // VR 성능상 그림자 off
private ParticleSystem _ps;
private ParticleSystemRenderer _renderer;
private Light _light;
private int _lastAlive;
private void Awake()
{
_ps = GetComponent<ParticleSystem>();
_renderer = GetComponent<ParticleSystemRenderer>();
}
private void OnEnable() => _lastAlive = _ps.particleCount; // 재활성화 시 첫 프레임 오발동 방지
private void LateUpdate()
{
int alive = _ps.particleCount;
if (alive > _lastAlive) // 새 파티클 등장 = 발사/폭발 순간
{
Vector3 pos = GetEventPosition();
if (UnityEngine.Random.value <= _sfxChance) PlaySfx(pos); // 사운드는 폭발 위치에서
if (_flashLight) _ = FlashLight(GetLightPosition(pos)); // 라이트는 (옵션) 지상 높이로 내려서
}
_lastAlive = alive;
}
// 실제로 그려지는 파티클의 월드 바운즈 중심 = 눈에 보이는 폭발 위치.
// 파티클 좌표를 직접 변환하면 시뮬레이션 스페이스·스케일링 모드(여기선 Local + 큰 스케일 10/30)와
// 안 맞아 위치가 크게 어긋난다. 렌더러 바운즈는 항상 월드 공간이라 스케일과 무관하게 정확하다.
private Vector3 GetEventPosition()
{
if (_renderer != null && _ps.particleCount > 0)
return _renderer.bounds.center;
return transform.position;
}
// 라이트를 놓을 위치: 범위 콜라이더가 있으면 그 안 무작위 지점, 없으면 폭발 지점.
// 폭죽은 수직으로 안 올라가고 옆으로 퍼지므로, NPC 조명은 폭발 XZ가 아니라 관객 구역 기준이어야 함.
private Vector3 GetLightPosition(Vector3 burstPos)
{
if (_lightArea == null) return burstPos;
Bounds b = _lightArea.bounds; // 월드 AABB 안에서 무작위
return new Vector3(
UnityEngine.Random.Range(b.min.x, b.max.x),
UnityEngine.Random.Range(b.min.y, b.max.y),
UnityEngine.Random.Range(b.min.z, b.max.z));
}
private void PlaySfx(Vector3 pos)
{
if (_sfx == null || _sfx.Length == 0) return;
AudioClip clip = _sfx[UnityEngine.Random.Range(0, _sfx.Length)];
if (clip == null) return;
if (SoundManager.Instance != null)
{
SoundManager.Instance.PlaySFXAt(clip, pos, _volume, _minDistance, _maxDistance); // 3D SFX 풀 재사용 (VR 공간음)
return;
}
// 매니저 없을 때 폴백: 거리 설정을 반영한 임시 3D 소스 (PlayClipAtPoint는 minDistance 조절 불가)
var go = new GameObject("FireworkSfx");
go.transform.position = pos;
var src = go.AddComponent<AudioSource>();
src.clip = clip;
src.volume = _volume;
src.spatialBlend = 1f;
src.rolloffMode = AudioRolloffMode.Logarithmic;
src.minDistance = _minDistance;
src.maxDistance = _maxDistance;
src.Play();
Destroy(go, clip.length);
}
// 라이트 하나를 만들어(재사용) 번쩍였다 끈다
private async Awaitable FlashLight(Vector3 pos)
{
if (_light == null)
{
var go = new GameObject("FlashLight");
go.transform.SetParent(transform);
_light = go.AddComponent<Light>();
_light.type = LightType.Point;
_light.shadows = _shadows;
_light.renderMode = LightRenderMode.ForcePixel;
}
_light.transform.position = pos;
_light.color = _lightColor;
_light.range = _lightRange;
_light.enabled = true;
try
{
float t = 0f;
while (t < _flashDuration)
{
_light.intensity = _lightIntensity * (1f - t / _flashDuration);
t += Time.deltaTime;
await Awaitable.NextFrameAsync(destroyCancellationToken);
}
}
catch (OperationCanceledException) { /* 파괴됨 */ }
finally
{
if (_light != null)
{
_light.intensity = 0f;
_light.enabled = false;
}
}
}
}