first
This commit is contained in:
23
Assets/02_Scripts/Managers/GameManager.cs
Normal file
23
Assets/02_Scripts/Managers/GameManager.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class GameManager : MonoBehaviour,ISceneInitializable
|
||||
{
|
||||
public static GameManager Instance { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this; //만들어진 자신을 인스턴스로 설정
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴
|
||||
}
|
||||
}
|
||||
|
||||
public void OnSceneLoaded()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/GameManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/GameManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 197a05ee04c0ac746adfedb27e01fdf5
|
||||
40
Assets/02_Scripts/Managers/InputManager.cs
Normal file
40
Assets/02_Scripts/Managers/InputManager.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
public class InputManager : MonoBehaviour, GameInput.IPlayerActions
|
||||
{
|
||||
// 외부에서 InputManager.Instance.OnXxx_Event += handler 형태로 구독.
|
||||
public static InputManager Instance { get; private set; }
|
||||
|
||||
private GameInput _input;
|
||||
|
||||
public event Action OnDialogNext_Event;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this; //만들어진 자신을 인스턴스로 설정
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴
|
||||
}
|
||||
|
||||
_input = new GameInput();
|
||||
_input.Player.SetCallbacks(this);
|
||||
}
|
||||
|
||||
// GameInput은 활성/비활성 토글이 필요한 자원 ?. 처리로 Awake보다 OnEnable이 먼저 호출되는 경우 보호.
|
||||
private void OnEnable() => _input?.Player.Enable();
|
||||
private void OnDisable() => _input?.Player.Disable();
|
||||
private void OnDestroy() => _input?.Dispose();
|
||||
|
||||
public void OnDialogNext(InputAction.CallbackContext ctx)
|
||||
{
|
||||
if (ctx.phase == InputActionPhase.Started)
|
||||
OnDialogNext_Event?.Invoke();
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/InputManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/InputManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 657e64bd5c92e4843a0d5b9892e05e45
|
||||
85
Assets/02_Scripts/Managers/LocationManager.cs
Normal file
85
Assets/02_Scripts/Managers/LocationManager.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
using UnityEngine;
|
||||
|
||||
// 게임 씬에 하나. 현재 장소 프리팹을 띄우고, 장소 안 캐릭터 슬롯들의 등장 여부를 갱신한다.
|
||||
// DialogPlayer가 대화 후보를 조회할 때 (Database, Current)를 여기서 가져간다.
|
||||
public class LocationManager : MonoBehaviour
|
||||
{
|
||||
public static LocationManager Instance { get; private set; }
|
||||
|
||||
[SerializeField] private StoryDatabase _database;
|
||||
|
||||
[Tooltip("장소 프리팹을 붙일 부모 (비우면 이 오브젝트)")]
|
||||
[SerializeField] private Transform _locationRoot;
|
||||
|
||||
[Tooltip("게임 시작 시 입장할 장소")]
|
||||
[SerializeField] private LocationData _startLocation;
|
||||
|
||||
public StoryDatabase Database => _database;
|
||||
public LocationData Current { get; private set; }
|
||||
|
||||
private GameObject _currentInstance;
|
||||
|
||||
// 대화 중 진행도/트리거가 바뀌었을 때의 슬롯 갱신 예약.
|
||||
// 대화 도중 화자를 꺼 버리면 재생이 깨지므로, 대화가 끝난 뒤로 미룬다.
|
||||
private bool _refreshPending;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
if (_locationRoot == null) _locationRoot = transform;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (StoryManager.Instance != null)
|
||||
StoryManager.Instance.Changed += OnStoryChanged;
|
||||
DialogPlayer.AnyActiveChanged += OnDialogActiveChanged;
|
||||
|
||||
if (_startLocation != null)
|
||||
MoveTo(_startLocation);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance != this) return;
|
||||
if (StoryManager.Instance != null)
|
||||
StoryManager.Instance.Changed -= OnStoryChanged;
|
||||
DialogPlayer.AnyActiveChanged -= OnDialogActiveChanged;
|
||||
Instance = null;
|
||||
}
|
||||
|
||||
// 장소 이동 (이동 버튼의 UnityEvent 연결용). 대화 중에는 무시된다.
|
||||
public void MoveTo(LocationData location)
|
||||
{
|
||||
if (location == null || DialogPlayer.IsAnyActive) return;
|
||||
|
||||
Current = location;
|
||||
if (_currentInstance != null) Destroy(_currentInstance);
|
||||
_currentInstance = location.Prefab != null
|
||||
? Instantiate(location.Prefab, _locationRoot)
|
||||
: null;
|
||||
RefreshSlots();
|
||||
}
|
||||
|
||||
// 대화로 진행도/트리거가 바뀌면 등장 캐릭터도 달라질 수 있다
|
||||
private void OnStoryChanged()
|
||||
{
|
||||
if (DialogPlayer.IsAnyActive) { _refreshPending = true; return; }
|
||||
RefreshSlots();
|
||||
}
|
||||
|
||||
private void OnDialogActiveChanged(bool active)
|
||||
{
|
||||
if (active || !_refreshPending) return;
|
||||
_refreshPending = false;
|
||||
RefreshSlots();
|
||||
}
|
||||
|
||||
private void RefreshSlots()
|
||||
{
|
||||
if (_currentInstance == null || _database == null) return;
|
||||
foreach (var slot in _currentInstance.GetComponentsInChildren<CharacterSlot>(includeInactive: true))
|
||||
slot.Refresh(_database, Current);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/LocationManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/LocationManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6244c1dafce01c04db303bbaf499f989
|
||||
156
Assets/02_Scripts/Managers/SceneLoadManager.cs
Normal file
156
Assets/02_Scripts/Managers/SceneLoadManager.cs
Normal file
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
public class SceneLoadManager : MonoBehaviour
|
||||
{
|
||||
public static SceneLoadManager Instance { get; private set; }
|
||||
|
||||
[SerializeField] private GameObject _loadingRoot;
|
||||
[SerializeField] private LoadingScreen _loadingScreen;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this; // 만들어진 자신을 인스턴스로 설정
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject); // 이미 인스턴스가 있으면 자신을 파괴
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
OnSceneLoaded(SceneManager.GetActiveScene(), LoadSceneMode.Single);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this)
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
MonoBehaviour[] allObjs = FindObjectsByType<MonoBehaviour>();
|
||||
|
||||
foreach (var obj in allObjs)
|
||||
{
|
||||
if (obj is ISceneInitializable initializable)
|
||||
{
|
||||
// 씬에서 ISceneInitializable 인터페이스를 가진 오브젝트의 초기화 로직을 실행
|
||||
initializable.OnSceneLoaded();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSceneLoadingProgressValue(float value)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public async Awaitable SetSceneLoadingActive(bool isActive,float alphaTime)
|
||||
{
|
||||
if (isActive)
|
||||
{
|
||||
_loadingRoot.SetActive(true);
|
||||
}
|
||||
|
||||
|
||||
if (alphaTime > 0f)
|
||||
{
|
||||
await FadeLoadingCanvas(!isActive,alphaTime);
|
||||
}
|
||||
|
||||
if (!isActive)
|
||||
_loadingRoot.SetActive(false);
|
||||
}
|
||||
|
||||
public async Awaitable FadeLoadingCanvas(bool isOut,float fadeTime)
|
||||
{
|
||||
float startAlpha = isOut ? 1f : 0f;
|
||||
float endAlpha = isOut ? 0f : 1f;
|
||||
|
||||
float timer = 0;
|
||||
_loadingScreen.LoadingScreenCanvasGroup.alpha = startAlpha;
|
||||
|
||||
while(timer < fadeTime)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
_loadingScreen.LoadingScreenCanvasGroup.alpha = Mathf.Lerp(startAlpha, endAlpha, timer / fadeTime);
|
||||
await Awaitable.NextFrameAsync(this.destroyCancellationToken);
|
||||
}
|
||||
|
||||
_loadingScreen.LoadingScreenCanvasGroup.alpha = endAlpha;
|
||||
}
|
||||
|
||||
public void RequestSceneChange(string sceneName)
|
||||
{
|
||||
_ = SceneChange(sceneName);
|
||||
}
|
||||
|
||||
private async Awaitable SceneChange(string sceneName)
|
||||
{
|
||||
try
|
||||
{
|
||||
//로딩바 수치 0으로 설정
|
||||
SetSceneLoadingProgressValue(0f);
|
||||
|
||||
//로딩창을 1초에 걸쳐 나타나게함
|
||||
await SetSceneLoadingActive(true,1f);
|
||||
|
||||
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName);
|
||||
|
||||
//자동 전환을 하고 싶지 않을 경우 해당값을 false로 두었다가 true로 바꾸면 그 때 전환됨
|
||||
op.allowSceneActivation = false;
|
||||
|
||||
//화면에 보여줄 로딩 수치
|
||||
float displayProgress = 0f;
|
||||
|
||||
//op.progress 0.9가 데이터 로딩이 끝난 기준 allowSceneActivation이 트루면 다음으로 넘어가면서 op.isDone이 true가 된다.
|
||||
while (op.progress < 0.9f)
|
||||
{
|
||||
//실제 로딩 수치
|
||||
float realProgress = Mathf.Clamp01(op.progress / 0.9f);
|
||||
|
||||
//보여줄 값을 실제값을 향해 부드럽게 이동
|
||||
displayProgress = Mathf.MoveTowards(displayProgress, realProgress, Time.deltaTime * 0.5f);
|
||||
|
||||
// 로딩바 UI에 값 적용
|
||||
SetSceneLoadingProgressValue(displayProgress);
|
||||
|
||||
//자기자신이 파괴될때 토큰에 취소요청을 보냄
|
||||
await Awaitable.NextFrameAsync(this.destroyCancellationToken);
|
||||
}
|
||||
|
||||
//로딩바 수치 1(100%)로 설정 (데이터 로딩은 이미 끝이기 때문에)
|
||||
SetSceneLoadingProgressValue(1);
|
||||
|
||||
// 잠시 대기했다가 전환
|
||||
await Awaitable.WaitForSecondsAsync(1.0f, this.destroyCancellationToken);
|
||||
|
||||
// 로딩바가 1초에 걸쳐 사라짐
|
||||
await SetSceneLoadingActive(false,1f);
|
||||
|
||||
// 다음씬으로 넘어가도 됨을 알림
|
||||
op.allowSceneActivation = true;
|
||||
|
||||
// 씬 활성화가 완전히 끝날 때까지 대기
|
||||
// allowSceneActivation가 true가 되고 완전히 전환되기까지는 몇프레임 걸림. op.isDone 은 이 과정이 끝난 뒤에 true가 됨.
|
||||
while(!op.isDone)
|
||||
{
|
||||
await Awaitable.NextFrameAsync(this.destroyCancellationToken);
|
||||
}
|
||||
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Debug.Log("씬 전환 작업이 취소됨");
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/SceneLoadManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/SceneLoadManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3e48e3fcdff9c843abca3c5583a43ad
|
||||
237
Assets/02_Scripts/Managers/SoundManager.cs
Normal file
237
Assets/02_Scripts/Managers/SoundManager.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
public class SoundManager : MonoBehaviour, ISceneInitializable
|
||||
{
|
||||
public static SoundManager Instance { get; private set; }
|
||||
|
||||
[Header("Mixer")]
|
||||
[SerializeField] private AudioMixer _mainMixer; //메인 믹서 하나
|
||||
[SerializeField] private AudioMixerGroup _bgmGroup;
|
||||
[SerializeField] private AudioMixerGroup _sfxGroup;
|
||||
|
||||
[Header("BGM")]
|
||||
[SerializeField] private AudioSource _bgmSource; //BGM 전용 단일 오디오 소스
|
||||
[SerializeField, Range(0f, 1f)] private float _bgmVolume = 1f;
|
||||
[SerializeField] private float _bgmFadeDuration = 1.5f;
|
||||
[SerializeField] private AudioClip _defaultBgm; //평상시 BGM (시작 시 자동 재생)
|
||||
|
||||
//대화 전용 BGM 등 일시적으로 기본 BGM을 덮는 곡. null이면 기본 BGM 재생 중.
|
||||
private AudioClip _overrideBgm;
|
||||
|
||||
[Header("SFX Pool")]
|
||||
[SerializeField] private int _sfxPoolSize = 20; //시작 시 미리 생성할 SFX 소스 개수
|
||||
|
||||
//SFX 풀 - 대기중인 소스
|
||||
private readonly Queue<AudioSource> _sfxPool = new();
|
||||
|
||||
//BGM 전환(페이드)을 취소하기 위한 토큰.
|
||||
private CancellationTokenSource _bgmCts;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this; //만들어진 자신을 인스턴스로 설정
|
||||
Initialize();
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴 (씬마다 놓아도 첫 번째만 남음)
|
||||
}
|
||||
}
|
||||
|
||||
// 씬이 바뀌면 이전 씬에서 남은 전용(Override) BGM을 정리한다 (ISceneInitializable).
|
||||
// 새 씬의 SceneBgm.Start가 SetDefaultBGM으로 그 씬의 곡을 넘겨주면 그대로 재생된다.
|
||||
public void OnSceneLoaded() => ClearOverrideBGM();
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
//BGM 소스 기본 설정
|
||||
if (_bgmSource != null)
|
||||
{
|
||||
_bgmSource.outputAudioMixerGroup = _bgmGroup;
|
||||
_bgmSource.playOnAwake = false;
|
||||
_bgmSource.loop = true;
|
||||
}
|
||||
|
||||
//SFX 풀 미리 생성
|
||||
for (int i = 0; i < _sfxPoolSize; i++)
|
||||
{
|
||||
_sfxPool.Enqueue(CreateSfxSource());
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (_defaultBgm != null)
|
||||
StartBgmChange(_defaultBgm);
|
||||
}
|
||||
|
||||
//=========================== BGM ===========================
|
||||
|
||||
//기본 BGM 교체 (씬/분위기 전환용). 전용 BGM 재생 중이면 곡은 유지되고 복귀 시 반영됨.
|
||||
public void SetDefaultBGM(AudioClip clip)
|
||||
{
|
||||
if (_defaultBgm == clip) return; //같은 곡이면 페이드 재시작 없이 유지
|
||||
_defaultBgm = clip;
|
||||
if (_overrideBgm == null)
|
||||
StartBgmChange(clip);
|
||||
}
|
||||
|
||||
//전용 BGM으로 전환 (대화 노드 등). 이미 같은 곡이 재생 중이면 아무것도 안 함.
|
||||
public void PlayOverrideBGM(AudioClip clip)
|
||||
{
|
||||
if (clip == null || _overrideBgm == clip) return;
|
||||
_overrideBgm = clip;
|
||||
StartBgmChange(clip);
|
||||
}
|
||||
|
||||
//전용 BGM을 멈추고 기본 BGM으로 복귀. 전용 BGM이 없었으면 아무것도 안 함.
|
||||
public void ClearOverrideBGM()
|
||||
{
|
||||
if (_overrideBgm == null) return;
|
||||
_overrideBgm = null;
|
||||
StartBgmChange(_defaultBgm);
|
||||
}
|
||||
|
||||
//진행 중이던 전환(페이드)을 취소하고 새 전환을 시작
|
||||
private void StartBgmChange(AudioClip clip)
|
||||
{
|
||||
if (_bgmSource == null) return;
|
||||
_bgmCts?.Cancel();
|
||||
_bgmCts?.Dispose();
|
||||
_bgmCts = new CancellationTokenSource();
|
||||
_ = ChangeBGM(clip, _bgmCts.Token);
|
||||
}
|
||||
|
||||
//페이드아웃 → 클립 교체 → 페이드인 (단일 소스라 순차 진행)
|
||||
private async Awaitable ChangeBGM(AudioClip clip, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
//현재 재생중이면 먼저 페이드아웃
|
||||
if (_bgmSource.isPlaying)
|
||||
{
|
||||
await BGMFade(_bgmSource.volume, 0f, token);
|
||||
_bgmSource.Stop();
|
||||
}
|
||||
|
||||
if (clip == null) return; //BGM이 없으면 무음 유지
|
||||
|
||||
//새 BGM 페이드인
|
||||
_bgmSource.clip = clip;
|
||||
_bgmSource.volume = 0f;
|
||||
_bgmSource.Play();
|
||||
await BGMFade(0f, _bgmVolume, token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
//전환이 취소됨
|
||||
}
|
||||
}
|
||||
|
||||
//BGM 소스 볼륨을 from→to로 부드럽게 이동
|
||||
private async Awaitable BGMFade(float from, float to, CancellationToken token)
|
||||
{
|
||||
float t = 0f;
|
||||
while (t < _bgmFadeDuration)
|
||||
{
|
||||
t += Time.deltaTime;
|
||||
_bgmSource.volume = Mathf.Lerp(from, to, t / _bgmFadeDuration);
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
}
|
||||
_bgmSource.volume = to;
|
||||
}
|
||||
|
||||
//=========================== SFX ===========================
|
||||
|
||||
//2D SFX 재생 (UI 사운드 등 위치 무관)
|
||||
public void PlaySFX(AudioClip clip, float volume = 1f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
|
||||
AudioSource source = GetSfxSource();
|
||||
source.transform.localPosition = Vector3.zero;
|
||||
source.spatialBlend = 0f; //2D
|
||||
Play(source, clip, volume);
|
||||
_ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납
|
||||
}
|
||||
|
||||
//3D SFX 재생 (VR 공간음향 - 특정 위치에서 들림)
|
||||
//minDistance: 이 거리 안에선 풀 볼륨으로 들림. 하늘 높이 터지는 폭죽처럼 멀리서 나는 큰 소리는
|
||||
//크게(예: 30~50) 잡아야 거리 감쇠로 사라지지 않는다. maxDistance: 감쇠 계산 상한.
|
||||
public void PlaySFXAt(AudioClip clip, Vector3 position, float volume = 1f,
|
||||
float minDistance = 1f, float maxDistance = 500f)
|
||||
{
|
||||
if (clip == null) return;
|
||||
|
||||
AudioSource source = GetSfxSource();
|
||||
source.transform.position = position;
|
||||
source.spatialBlend = 1f; //3D
|
||||
source.rolloffMode = AudioRolloffMode.Logarithmic;
|
||||
source.minDistance = minDistance;
|
||||
source.maxDistance = maxDistance;
|
||||
Play(source, clip, volume);
|
||||
_ = ReturnAfterPlay(source, clip.length); //재생이 끝나면 풀에 반납
|
||||
}
|
||||
|
||||
private void Play(AudioSource source, AudioClip clip, float volume)
|
||||
{
|
||||
source.clip = clip;
|
||||
source.volume = volume;
|
||||
source.Play();
|
||||
}
|
||||
|
||||
//풀에서 사용 가능한 SFX 소스를 가져옴 (없으면 새로 생성 - 자동 증설)
|
||||
private AudioSource GetSfxSource()
|
||||
{
|
||||
AudioSource source = _sfxPool.Count > 0 ? _sfxPool.Dequeue() : CreateSfxSource();
|
||||
source.gameObject.SetActive(true);
|
||||
return source;
|
||||
}
|
||||
|
||||
//재생 길이만큼 대기 후 소스를 풀로 반납
|
||||
private async Awaitable ReturnAfterPlay(AudioSource source, float duration)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Awaitable.WaitForSecondsAsync(duration, this.destroyCancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; //매니저 파괴 시 종료
|
||||
}
|
||||
|
||||
source.Stop();
|
||||
source.clip = null;
|
||||
source.gameObject.SetActive(false);
|
||||
_sfxPool.Enqueue(source);
|
||||
}
|
||||
|
||||
//SFX용 AudioSource를 자식 오브젝트로 생성
|
||||
private AudioSource CreateSfxSource()
|
||||
{
|
||||
GameObject go = new("SFX_Source");
|
||||
go.transform.SetParent(transform);
|
||||
go.SetActive(false);
|
||||
|
||||
AudioSource source = go.AddComponent<AudioSource>();
|
||||
source.outputAudioMixerGroup = _sfxGroup;
|
||||
source.playOnAwake = false;
|
||||
return source;
|
||||
}
|
||||
|
||||
//=========================== Mixer ===========================
|
||||
|
||||
//믹서 노출 파라미터로 볼륨 조절 (0~1 → dB 변환)
|
||||
public void SetVolume(string exposedParam, float normalized)
|
||||
{
|
||||
//0에 가까우면 -80dB(무음), 그 외에는 로그 스케일로 변환
|
||||
float db = normalized <= 0.0001f ? -80f : Mathf.Log10(normalized) * 20f;
|
||||
_mainMixer.SetFloat(exposedParam, db);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/SoundManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/SoundManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6de75b1b76588eb4280f8d111def36b7
|
||||
152
Assets/02_Scripts/Managers/StoryManager.cs
Normal file
152
Assets/02_Scripts/Managers/StoryManager.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
// 이야기 상태(StoryState)의 유일한 접근 창구.
|
||||
// 진행도/호감도/이력의 조회·변경과 저장/로드를 담당한다.
|
||||
// 씬에 미리 배치하지 않아도 첫 접근 때 자동 생성된다.
|
||||
public class StoryManager : MonoBehaviour
|
||||
{
|
||||
public static StoryManager Instance { get; private set; }
|
||||
|
||||
[Tooltip("플레이 시작 시 메인 진행도 초기값 — 씬을 직접 열어 테스트할 때 그 씬 기준 진행도로 시작하게. " +
|
||||
"이 매니저가 살아남는 씬(플레이를 시작한 씬)의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다")]
|
||||
[Min(0)] [SerializeField] private int _initialMainProgress;
|
||||
|
||||
[Serializable]
|
||||
private struct InitialAffection
|
||||
{
|
||||
public CharacterData Character;
|
||||
public int Amount;
|
||||
}
|
||||
|
||||
[Tooltip("플레이 시작 시 호감도 초기값 — 이 씬만 직접 열어 테스트할 때 앞 과정 없이 원하는 호감도로 시작하게. " +
|
||||
"진행도 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다")]
|
||||
[SerializeField] private List<InitialAffection> _initialAffections = new();
|
||||
|
||||
private StoryState _state = new();
|
||||
|
||||
// 상태가 바뀔 때마다 발행 (호감도 게이지 등 UI 갱신용)
|
||||
public event Action Changed;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance == null)
|
||||
{
|
||||
Instance = this; //만들어진 자신을 인스턴스로 설정
|
||||
_state.MainProgress = _initialMainProgress; // 테스트용 초기 진행도 (씬 전환으로 넘어온 중복은 여기 못 옴)
|
||||
|
||||
// 테스트용 초기 호감도 (첫 인스턴스에서만 — 씬 전환으로 넘어온 경우엔 이 자리에 못 옴)
|
||||
foreach (var init in _initialAffections)
|
||||
if (init.Character != null)
|
||||
_state.Affection[IdOf(init.Character)] = init.Amount;
|
||||
}
|
||||
else
|
||||
{
|
||||
Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴
|
||||
}
|
||||
}
|
||||
|
||||
// ── 메인 진행도 ──────────────────────────────────────────────
|
||||
public int MainProgress
|
||||
{
|
||||
get => _state.MainProgress;
|
||||
set
|
||||
{
|
||||
if (_state.MainProgress == value) return;
|
||||
_state.MainProgress = value;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
// UnityEvent 연결용 (버튼·이벤트존·노드 이벤트 등에서 진행도 조작)
|
||||
public void AddMainProgress(int amount) => MainProgress += amount;
|
||||
public void SetMainProgress(int value) => MainProgress = value;
|
||||
|
||||
// ── 호감도 ──────────────────────────────────────────────────
|
||||
public int GetAffection(CharacterData character)
|
||||
=> character != null && _state.Affection.TryGetValue(IdOf(character), out var v) ? v : 0;
|
||||
|
||||
public void AddAffection(CharacterData character, int delta)
|
||||
{
|
||||
if (character == null || delta == 0) return;
|
||||
_state.Affection[IdOf(character)] = GetAffection(character) + delta;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
// CharacterData의 Id를 키로 사용 (비어있으면 에셋 이름)
|
||||
private static string IdOf(CharacterData c) => string.IsNullOrEmpty(c.Id) ? c.name : c.Id;
|
||||
|
||||
// ── 대화 이력 ────────────────────────────────────────────────
|
||||
public bool IsDialogCompleted(string groupName) => _state.CompletedDialogs.Contains(groupName);
|
||||
|
||||
// 완료 기록. 처음 완료한 경우에만 true (진행도 보상 중복 방지용)
|
||||
public bool MarkDialogCompleted(string groupName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(groupName) || !_state.CompletedDialogs.Add(groupName))
|
||||
return false;
|
||||
Changed?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 트리거 ──────────────────────────────────────────────────
|
||||
public bool HasTrigger(string id) => _state.Triggers.Contains(id);
|
||||
|
||||
// UnityEvent 연결용 (버튼·이벤트 등에서 트리거 켜기). 대화 조건 RequiredTriggerIds에서 검사한다.
|
||||
public void SetTrigger(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || !_state.Triggers.Add(id)) return;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
// ── 선택 이력 ────────────────────────────────────────────────
|
||||
public bool HasChosen(string code) => _state.ChosenCodes.Contains(code);
|
||||
|
||||
public void RecordChoice(string code)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code) || !_state.ChosenCodes.Add(code)) return;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
// ── 저장 / 로드 ──────────────────────────────────────────────
|
||||
// 플레이 시작 시엔 항상 빈 상태로 시작한다 (테스트 반복이 꼬이지 않게).
|
||||
// 이어하기를 만들 때 타이틀 화면 등에서 Load()를 호출하면 된다.
|
||||
private static string SavePath => Path.Combine(Application.persistentDataPath, "story_state.json");
|
||||
|
||||
public void Save() => File.WriteAllText(SavePath, _state.ToJson());
|
||||
|
||||
public void CallLoad() => Load();
|
||||
// 저장 파일이 있으면 불러온다. 성공 여부 반환.
|
||||
public bool Load()
|
||||
{
|
||||
if (!File.Exists(SavePath))
|
||||
{
|
||||
Debug.LogWarning("세이브 파일이 없습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var loaded = StoryState.FromJson(File.ReadAllText(SavePath));
|
||||
if (loaded == null)
|
||||
{
|
||||
Debug.LogWarning("세이브 파일이 잘못되었습니다.");
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = loaded;
|
||||
Changed?.Invoke();
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[StoryManager] 저장 파일 로드 실패: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter Play Mode에서 도메인 리로드를 꺼도 이전 인스턴스 참조가 안 남게
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ResetStatics() => Instance = null;
|
||||
}
|
||||
2
Assets/02_Scripts/Managers/StoryManager.cs.meta
Normal file
2
Assets/02_Scripts/Managers/StoryManager.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f27662344dffe4a867f63cf1605d63
|
||||
Reference in New Issue
Block a user