diff --git a/Assets/01_Scenes/Hischool.unity b/Assets/01_Scenes/Hischool.unity index 5003496c..d6a7494c 100644 --- a/Assets/01_Scenes/Hischool.unity +++ b/Assets/01_Scenes/Hischool.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aeb54ce5d82594a020e84f1f41b0c2b4640509389cc486538b2b86d4f6fb264c -size 8190 +oid sha256:54ea6c415427a20f3934c27b88c2a0006c1032c61c8de1ccaa65b73df121bbc1 +size 10621 diff --git a/Assets/01_Scenes/MyHouse.unity b/Assets/01_Scenes/MyHouse.unity index 92e5ee22..f4997e95 100644 --- a/Assets/01_Scenes/MyHouse.unity +++ b/Assets/01_Scenes/MyHouse.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:85150caae8ca83c6876eebfe39575714fe765df2ed28de9d4c31344f93e55b89 -size 8190 +oid sha256:1ffc8be8624be5b747aa912702893301f1edff1f806bbaebb72877caef30d763 +size 10613 diff --git a/Assets/01_Scenes/Street1.unity b/Assets/01_Scenes/Street1.unity index d34a5060..696e6f28 100644 --- a/Assets/01_Scenes/Street1.unity +++ b/Assets/01_Scenes/Street1.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9774d5d941d56f18de310d5fc2752afeaa54f29fbba31f8ab0daa9685aaa50a -size 8198 +oid sha256:4ac0653d3b99173d27ffad154583ae0ed83e8d18c6b4d3a8d75bb072ee4ed5dd +size 10621 diff --git a/Assets/01_Scenes/TestScenes/Test1.unity b/Assets/01_Scenes/TestScenes/Test1.unity index 427636c0..f3d1b9ec 100644 --- a/Assets/01_Scenes/TestScenes/Test1.unity +++ b/Assets/01_Scenes/TestScenes/Test1.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7247837b6d1152e241087b5cd58b80d902917ced0988c89b215fc0d494d28cc8 -size 8198 +oid sha256:79d96b387b9f4bdda0471158668e0253bc4c05e2f08d352406a53360b2cae597 +size 10621 diff --git a/Assets/02_Scripts/Communication/Dialog/AffectionModifier.cs b/Assets/02_Scripts/Communication/Dialog/AffectionModifier.cs index 7eb0456b..1f2db945 100644 --- a/Assets/02_Scripts/Communication/Dialog/AffectionModifier.cs +++ b/Assets/02_Scripts/Communication/Dialog/AffectionModifier.cs @@ -7,5 +7,5 @@ public class AffectionModifier : MonoBehaviour { [SerializeField] private CharacterData _character; - public void Add(int delta) => StoryState.AddAffection(_character, delta); + public void Add(int delta) => StoryManager.Instance.AddAffection(_character, delta); } diff --git a/Assets/02_Scripts/Communication/Dialog/DialogCondition.cs b/Assets/02_Scripts/Communication/Dialog/DialogCondition.cs index 5a513bda..155b96f8 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogCondition.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogCondition.cs @@ -22,18 +22,20 @@ public class DialogCondition // affectionTarget: 호감도 조건을 검사할 캐릭터 (보통 대화를 거는 NPC 자신) public bool IsMet(CharacterData affectionTarget) { - if (StoryState.MainProgress < MinMainProgress) + var story = StoryManager.Instance; + + if (story.MainProgress < MinMainProgress) return false; - if (MinAffection > 0 && StoryState.GetAffection(affectionTarget) < MinAffection) + if (MinAffection > 0 && story.GetAffection(affectionTarget) < MinAffection) return false; foreach (var group in RequiredDialogs) - if (group != null && !StoryState.IsDialogCompleted(group.name)) + if (group != null && !story.IsDialogCompleted(group.name)) return false; foreach (var code in RequiredChoiceCodes) - if (!string.IsNullOrEmpty(code) && !StoryState.HasChosen(code)) + if (!string.IsNullOrEmpty(code) && !story.HasChosen(code)) return false; return true; diff --git a/Assets/02_Scripts/Communication/Dialog/DialogNode.cs b/Assets/02_Scripts/Communication/Dialog/DialogNode.cs index c3fbcd60..7d263fa7 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogNode.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogNode.cs @@ -19,6 +19,10 @@ public class DialogNode : ScriptableObject //Voice 없음 → LineDuration 대기 + [Header("Presentation")] + public AudioClip Bgm; // 있으면 이 대사부터 전용 BGM 재생, 비어있으면 기본 BGM으로 복귀 + public GameObject Vfx; // 이 대사 시작 시 화자 위치에서 1회 재생할 이펙트 프리팹 + [Header("Behavior")] public bool LookAtPlayer; public bool WaitForInput; // true면 LineDuration 무시하고 B버튼(OnDialogNext) 입력까지 대기 diff --git a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs index 801b5ce4..86cd958a 100644 --- a/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs +++ b/Assets/02_Scripts/Communication/Dialog/DialogPlayer.cs @@ -86,7 +86,7 @@ private int FindPlayableIndex() { var entry = _dialogs[i]; if (entry.Group == null) continue; - if (!entry.Repeatable && StoryState.IsDialogCompleted(entry.Group.name)) continue; + if (!entry.Repeatable && StoryManager.Instance.IsDialogCompleted(entry.Group.name)) continue; if (entry.Condition != null && !entry.Condition.IsMet(_voice.Character)) continue; return i; } @@ -117,10 +117,11 @@ private async Awaitable PlayEntry(DialogEntry entry) // 여기까지 왔으면 자연 종료(끝까지 재생) — 이때만 완료로 기록한다. // (중간에 오브젝트 파괴 등으로 끊기면 예외로 빠져나가 기록되지 않음) - bool firstTime = StoryState.MarkDialogCompleted(entry.Group.name); + var story = StoryManager.Instance; + bool firstTime = story.MarkDialogCompleted(entry.Group.name); if (firstTime && entry.ProgressOnComplete > 0) - StoryState.MainProgress += entry.ProgressOnComplete; - StoryState.Save(); + story.MainProgress += entry.ProgressOnComplete; + story.Save(); Debug.Log($"[DialogPlayer] 대화 종료: {entry.Group.name}"); } @@ -129,6 +130,8 @@ private async Awaitable PlayEntry(DialogEntry entry) IsPlaying = false; if (DialogHud.Instance != null) DialogHud.Instance.Hide(); + if (SoundManager.Instance != null) + SoundManager.Instance.ClearOverrideBGM(); // 대화가 끝나면 기본 BGM으로 복귀 RestoreDefaultAnimations(); RestoreRotations(); } @@ -195,6 +198,28 @@ private async Awaitable PlayNode(DialogNode node) RaiseNodeEvent(node.EventKey); // EventKey 있으면 매칭 이벤트 호출 + // 전용 BGM: 설정돼 있으면 교체, 비어 있으면 기본 BGM으로 복귀 + if (SoundManager.Instance != null) + { + if (node.Bgm != null) + SoundManager.Instance.PlayOverrideBGM(node.Bgm); + else + SoundManager.Instance.ClearOverrideBGM(); + } + + // 전용 VFX: 화자 위치에서 1회 재생 + if (node.Vfx != null) + { + var speakerObj = node.Speaker != null ? CharacterVoiceObject.Find(node.Speaker) : null; + var anchor = speakerObj != null ? speakerObj.transform : transform; + var vfx = Instantiate(node.Vfx, anchor.position, anchor.rotation); + + // 파티클이면 재생 길이만큼, 아니면 5초 뒤 자동 제거 + var ps = vfx.GetComponentInChildren(); + float life = ps != null ? ps.main.duration + ps.main.startLifetime.constantMax : 5f; + Destroy(vfx, life); + } + // 보이스 재생 if (node.Voice != null && node.Speaker != null) { @@ -253,7 +278,7 @@ private void RecordChoice(DialogNode node, int index) code = DialogVariables.Format(code); // {token} 치환 → 동적으로 생성된 코드 반영 if (!string.IsNullOrEmpty(code)) - StoryState.RecordChoice(code); + StoryManager.Instance.RecordChoice(code); } private async Awaitable WaitForChoice(DialogNode node) diff --git a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs index aa9e4f80..1dc27c71 100644 --- a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs +++ b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogGraphImporter.cs @@ -10,7 +10,7 @@ namespace DinoLove.Dialog.GraphTool.Editor // .dlg 그래프 에셋을 기존 런타임 타입(DialogGroup / DialogNode / DialogChoice)으로 변환한다. // 생성된 DialogNode들은 서브에셋으로, DialogGroup이 메인 에셋으로 등록된다. // 따라서 DialogPlayer는 수정 없이 임포트된 .dlg 에셋(= DialogGroup)을 그대로 사용한다. - [ScriptedImporter(1, DialogGraph.AssetExtension)] + [ScriptedImporter(2, DialogGraph.AssetExtension)] // 버전 올리면 기존 .dlg 에셋이 재임포트됨 internal class DialogGraphImporter : ScriptedImporter { public override void OnImportAsset(AssetImportContext ctx) @@ -79,6 +79,8 @@ public override void OnImportAsset(AssetImportContext ctx) dn.Gesture = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_GESTURE)); dn.Expression = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION)); dn.Voice = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_VOICE)); + dn.Bgm = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_BGM)); + dn.Vfx = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_VFX)); dn.LineDuration = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_DURATION)); dn.LookAtPlayer = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_LOOKAT)); dn.WaitForInput = GetInputPortValue(gn.GetInputPortByName(DialogLineNode.PORT_WAITINPUT)); diff --git a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs index e866321f..ca04b15d 100644 --- a/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs +++ b/Assets/02_Scripts/Communication/Dialog/GraphTool/Editor/DialogLineNode.cs @@ -1,5 +1,6 @@ using System; using Unity.GraphToolkit.Editor; +using UnityEngine; namespace DinoLove.Dialog.GraphTool.Editor { @@ -18,6 +19,8 @@ internal class DialogLineNode : DialogGraphNode public const string PORT_GESTURE = "Gesture"; public const string PORT_EXPRESSION = "Expression"; public const string PORT_VOICE = "Voice"; + public const string PORT_BGM = "Bgm"; + public const string PORT_VFX = "Vfx"; public const string PORT_DURATION = "LineDuration"; public const string PORT_LOOKAT = "LookAtPlayer"; public const string PORT_WAITINPUT = "WaitForInput"; @@ -55,6 +58,10 @@ protected override void OnDefinePorts(IPortDefinitionContext context) context.AddInputPort(PORT_GESTURE).WithDisplayName("Gesture").Build(); context.AddInputPort(PORT_EXPRESSION).WithDisplayName("Expression").Build(); context.AddInputPort(PORT_VOICE).WithDisplayName("Voice").Build(); + context.AddInputPort(PORT_BGM).WithDisplayName("BGM") + .WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build(); + context.AddInputPort(PORT_VFX).WithDisplayName("VFX Prefab") + .WithTooltip("이 대사 시작 시 화자 위치에서 1회 재생할 이펙트 프리팹").Build(); context.AddInputPort(PORT_DURATION).WithDisplayName("Line Duration").Build(); context.AddInputPort(PORT_LOOKAT).WithDisplayName("Look At Player").Build(); context.AddInputPort(PORT_WAITINPUT).WithDisplayName("Wait For Input").Build(); diff --git a/Assets/02_Scripts/ISceneInitializable.cs b/Assets/02_Scripts/ISceneInitializable.cs new file mode 100644 index 00000000..0a35c0dd --- /dev/null +++ b/Assets/02_Scripts/ISceneInitializable.cs @@ -0,0 +1,4 @@ +public interface ISceneInitializable +{ + public void OnSceneLoaded(); +} \ No newline at end of file diff --git a/Assets/02_Scripts/ISceneInitializable.cs.meta b/Assets/02_Scripts/ISceneInitializable.cs.meta new file mode 100644 index 00000000..5748e0aa --- /dev/null +++ b/Assets/02_Scripts/ISceneInitializable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 38039c1904e850146a01668e869851be \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/GameManager.cs b/Assets/02_Scripts/Managers/GameManager.cs index 8b780c41..f76fbb2d 100644 --- a/Assets/02_Scripts/Managers/GameManager.cs +++ b/Assets/02_Scripts/Managers/GameManager.cs @@ -2,15 +2,5 @@ public class GameManager : MonoBehaviour { - // Start is called once before the first execution of Update after the MonoBehaviour is created - void Start() - { - - } - - // Update is called once per frame - void Update() - { - - } + public static GameManager Instance { get; private set; } } diff --git a/Assets/02_Scripts/Managers/SceneLoadManager.cs b/Assets/02_Scripts/Managers/SceneLoadManager.cs new file mode 100644 index 00000000..95719fa9 --- /dev/null +++ b/Assets/02_Scripts/Managers/SceneLoadManager.cs @@ -0,0 +1,169 @@ +using System; +using UnityEngine; +using UnityEngine.SceneManagement; + +public class SceneLoadManager : MonoBehaviour +{ + public static SceneLoadManager Instance { get; private set; } + + [SerializeField] private GameObject _loadingRoot; + [SerializeField] private Camera _loadingCam; + [SerializeField] private Transform _loadingCamTargetTransform; + + private bool _isChangingScene = false; + + public bool IsChangingScene => _isChangingScene; + + private void Awake() + { + if (Instance == null) + { + Instance = this; // 만들어진 자신을 인스턴스로 설정 + DontDestroyOnLoad(gameObject); + } + 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 Update() + { + if (_loadingRoot != null && _loadingCamTargetTransform != null) + { + _loadingRoot.transform.position = _loadingCamTargetTransform.position; + } + } + + // 씬이 로드되었을때 호출 + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + MonoBehaviour[] allObjs = FindObjectsByType(); + + foreach (var obj in allObjs) + { + if (obj is ISceneInitializable initializable) + { + // 씬에서 ISceneInitializable 인터페이스를 가진 오브젝트의 초기화 로직을 실행 + initializable.OnSceneLoaded(); + } + } + } + + public void SetSceneLoadingProgressValue(float value) + { + // 여기에 로딩바 UI 연결 예정 + } + + public void RequestSceneChange(string sceneName) + { + if (_isChangingScene) + { + Debug.Log("이미 씬 전환 중입니다."); + return; + } + + if (string.IsNullOrEmpty(sceneName)) + { + Debug.LogWarning("이동할 씬 이름이 비어있습니다."); + return; + } + + _ = SceneChange(sceneName); + } + + private async Awaitable SceneChange(string sceneName) + { + try + { + _isChangingScene = true; + + // 로딩바 수치 0으로 설정 + SetSceneLoadingProgressValue(0f); + + if (_loadingRoot != null) + { + _loadingRoot.SetActive(true); + } + + AsyncOperation op = SceneManager.LoadSceneAsync(sceneName); + + if (op == null) + { + Debug.LogError($"씬 로드 실패: {sceneName}"); + _isChangingScene = false; + return; + } + + // 자동 전환을 하고 싶지 않을 경우 false로 두었다가 true로 바꾸면 그 때 전환됨 + op.allowSceneActivation = false; + + // 화면에 보여줄 로딩 수치 + float displayProgress = 0f; + + // op.progress 0.9가 데이터 로딩이 끝난 기준 + 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(1f); + + // 잠시 대기했다가 전환 + await Awaitable.WaitForSecondsAsync(1.0f, this.destroyCancellationToken); + + // 다음씬으로 넘어가도 됨을 알림 + op.allowSceneActivation = true; + + // 씬 활성화가 완전히 끝날 때까지 대기 + while (!op.isDone) + { + await Awaitable.NextFrameAsync(this.destroyCancellationToken); + } + + // VR용 로직 + // 트래킹이 중단되면 안되기 때문에 카메라를 유지해야 한다 + if (Camera.main != null) + { + _loadingCamTargetTransform = Camera.main.transform; + } + + if (_loadingRoot != null) + { + _loadingRoot.SetActive(false); + } + + _isChangingScene = false; + } + catch (OperationCanceledException) + { + Debug.Log("씬 전환 작업이 취소됨"); + _isChangingScene = false; + } + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/SceneLoadManager.cs.meta b/Assets/02_Scripts/Managers/SceneLoadManager.cs.meta new file mode 100644 index 00000000..7fc2926b --- /dev/null +++ b/Assets/02_Scripts/Managers/SceneLoadManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e3e48e3fcdff9c843abca3c5583a43ad \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/SoundManager.cs b/Assets/02_Scripts/Managers/SoundManager.cs index 60a4ef1e..79b11e6b 100644 --- a/Assets/02_Scripts/Managers/SoundManager.cs +++ b/Assets/02_Scripts/Managers/SoundManager.cs @@ -1,16 +1,226 @@ +using System; +using System.Collections.Generic; +using System.Threading; using UnityEngine; +using UnityEngine.Audio; public class SoundManager : MonoBehaviour { - // Start is called once before the first execution of Update after the MonoBehaviour is created - void Start() + 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 _sfxPool = new(); + + //BGM 전환(페이드)을 취소하기 위한 토큰. + private CancellationTokenSource _bgmCts; + + private void Awake() { - + if (Instance == null) + { + Instance = this; //만들어진 자신을 인스턴스로 설정 + Initialize(); + } + else + { + Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴 + } } - // Update is called once per frame - void Update() + 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) + { + _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 공간음향 - 특정 위치에서 들림) + public void PlaySFXAt(AudioClip clip, Vector3 position, float volume = 1f) + { + if (clip == null) return; + + AudioSource source = GetSfxSource(); + source.transform.position = position; + source.spatialBlend = 1f; //3D + 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(); + 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); + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/StoryManager.cs b/Assets/02_Scripts/Managers/StoryManager.cs new file mode 100644 index 00000000..96e81b60 --- /dev/null +++ b/Assets/02_Scripts/Managers/StoryManager.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using UnityEngine; + +// 이야기 상태(StoryState)의 유일한 접근 창구. +// 진행도/호감도/이력의 조회·변경과 저장/로드를 담당한다. +// 씬에 미리 배치하지 않아도 첫 접근 때 자동 생성된다. +public class StoryManager : MonoBehaviour +{ + public static StoryManager Instance { get; private set; } + + private StoryState _state = new(); + + // 상태가 바뀔 때마다 발행 (호감도 게이지 등 UI 갱신용) + public event Action Changed; + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(gameObject); + return; + } + Instance = this; + DontDestroyOnLoad(gameObject); + } + + // ── 메인 진행도 ────────────────────────────────────────────── + public int MainProgress + { + get => _state.MainProgress; + set + { + if (_state.MainProgress == value) return; + _state.MainProgress = value; + Changed?.Invoke(); + } + } + + // ── 호감도 ────────────────────────────────────────────────── + 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 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 bool Load() + { + if (!File.Exists(SavePath)) return false; + try + { + var loaded = StoryState.FromJson(File.ReadAllText(SavePath)); + if (loaded == null) return false; + _state = loaded; + Changed?.Invoke(); + return true; + } + catch (Exception e) + { + Debug.LogWarning($"[StoryManager] 저장 파일 로드 실패: {e.Message}"); + return false; + } + } + + // 새 게임용 초기화 (저장 파일은 다음 Save 때 덮어써짐) + public void ResetAll() + { + _state.Clear(); + Changed?.Invoke(); + } + + // Enter Play Mode에서 도메인 리로드를 꺼도 이전 인스턴스 참조가 안 남게 + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStatics() => Instance = null; +} diff --git a/Assets/02_Scripts/Managers/StoryManager.cs.meta b/Assets/02_Scripts/Managers/StoryManager.cs.meta new file mode 100644 index 00000000..10389df8 --- /dev/null +++ b/Assets/02_Scripts/Managers/StoryManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f3f27662344dffe4a867f63cf1605d63 \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/StoryState.cs b/Assets/02_Scripts/Managers/StoryState.cs deleted file mode 100644 index 7b01eda8..00000000 --- a/Assets/02_Scripts/Managers/StoryState.cs +++ /dev/null @@ -1,135 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using UnityEngine; - -// 이야기 진행 상태의 중앙 저장소 (메인 진행도 / 호감도 / 대화 이력 / 선택 이력). -// DialogCondition이 대화 활성화 판정의 근거로 조회하고, DialogPlayer가 기록한다. -// -// 저장: Save()가 JSON 파일로 기록한다. DialogPlayer가 대화 완료 시마다 호출한다. -// 로드: 플레이 시작 시 항상 빈 상태로 시작한다 (테스트 반복이 꼬이지 않게). -// 이어하기를 만들 때 타이틀 화면 등에서 Load()를 호출하면 된다. -public static class StoryState -{ - private static int _mainProgress; - private static readonly Dictionary _affection = new(); - private static readonly HashSet _completedDialogs = new(); - private static readonly HashSet _chosenCodes = new(); - - // 상태가 바뀔 때마다 발행 (호감도 게이지 등 UI 갱신용) - public static event Action Changed; - - // ── 메인 진행도 ────────────────────────────────────────────── - public static int MainProgress - { - get => _mainProgress; - set - { - if (_mainProgress == value) return; - _mainProgress = value; - Changed?.Invoke(); - } - } - - // ── 호감도 ────────────────────────────────────────────────── - public static int GetAffection(CharacterData character) - => character != null && _affection.TryGetValue(IdOf(character), out var v) ? v : 0; - - public static void AddAffection(CharacterData character, int delta) - { - if (character == null || delta == 0) return; - _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 static bool IsDialogCompleted(string groupName) => _completedDialogs.Contains(groupName); - - // 완료 기록. 처음 완료한 경우에만 true (진행도 보상 중복 방지용) - public static bool MarkDialogCompleted(string groupName) - { - if (string.IsNullOrEmpty(groupName) || !_completedDialogs.Add(groupName)) - return false; - Changed?.Invoke(); - return true; - } - - // ── 선택 이력 ──────────────────────────────────────────────── - public static bool HasChosen(string code) => _chosenCodes.Contains(code); - - public static void RecordChoice(string code) - { - if (string.IsNullOrEmpty(code) || !_chosenCodes.Add(code)) return; - Changed?.Invoke(); - } - - // ── 저장 / 로드 ────────────────────────────────────────────── - [Serializable] - private class SaveData - { - public int MainProgress; - public List AffectionIds = new(); - public List AffectionValues = new(); - public List CompletedDialogs = new(); - public List ChosenCodes = new(); - } - - private static string SavePath => Path.Combine(Application.persistentDataPath, "story_state.json"); - - public static void Save() - { - var data = new SaveData { MainProgress = _mainProgress }; - foreach (var kvp in _affection) - { - data.AffectionIds.Add(kvp.Key); - data.AffectionValues.Add(kvp.Value); - } - data.CompletedDialogs.AddRange(_completedDialogs); - data.ChosenCodes.AddRange(_chosenCodes); - File.WriteAllText(SavePath, JsonUtility.ToJson(data, prettyPrint: true)); - } - - // 저장 파일이 있으면 불러온다. 성공 여부 반환. - public static bool Load() - { - if (!File.Exists(SavePath)) return false; - - var data = JsonUtility.FromJson(File.ReadAllText(SavePath)); - if (data == null) return false; - - _mainProgress = data.MainProgress; - _affection.Clear(); - for (int i = 0; i < data.AffectionIds.Count && i < data.AffectionValues.Count; i++) - _affection[data.AffectionIds[i]] = data.AffectionValues[i]; - _completedDialogs.Clear(); - _completedDialogs.UnionWith(data.CompletedDialogs); - _chosenCodes.Clear(); - _chosenCodes.UnionWith(data.ChosenCodes); - Changed?.Invoke(); - return true; - } - - // 새 게임용 초기화 (저장 파일은 다음 Save 때 덮어써짐) - public static void ResetAll() - { - _mainProgress = 0; - _affection.Clear(); - _completedDialogs.Clear(); - _chosenCodes.Clear(); - Changed?.Invoke(); - } - - // 플레이 시작마다 초기화 (Enter Play Mode에서 도메인 리로드를 꺼도 이전 값이 안 남게) - [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] - private static void ResetOnPlay() - { - _mainProgress = 0; - _affection.Clear(); - _completedDialogs.Clear(); - _chosenCodes.Clear(); - Changed = null; - } -} diff --git a/Assets/04_Models/dumy.txt.meta b/Assets/02_Scripts/Story.meta similarity index 57% rename from Assets/04_Models/dumy.txt.meta rename to Assets/02_Scripts/Story.meta index d384d1c5..fe24732f 100644 --- a/Assets/04_Models/dumy.txt.meta +++ b/Assets/02_Scripts/Story.meta @@ -1,6 +1,7 @@ fileFormatVersion: 2 -guid: b8ee6a43c79b269439e262f79b4d84c9 -TextScriptImporter: +guid: 9720d44bfa10d5c4eaadac3921ee3d42 +folderAsset: yes +DefaultImporter: externalObjects: {} userData: assetBundleName: diff --git a/Assets/02_Scripts/Story/StoryState.cs b/Assets/02_Scripts/Story/StoryState.cs new file mode 100644 index 00000000..483759ac --- /dev/null +++ b/Assets/02_Scripts/Story/StoryState.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +// 이야기 진행 상태 데이터 (메인 진행도 / 호감도 / 대화 이력 / 선택 이력). +// 상태 보관과 JSON 변환만 담당한다 — 게임 로직에서는 StoryManager를 통해 접근할 것. +public class StoryState +{ + public int MainProgress; + public readonly Dictionary Affection = new(); // 캐릭터 Id → 호감도 + public readonly HashSet CompletedDialogs = new(); // 완료한 DialogGroup 이름 + public readonly HashSet ChosenCodes = new(); // 골랐던 선택지 Code + + public void Clear() + { + MainProgress = 0; + Affection.Clear(); + CompletedDialogs.Clear(); + ChosenCodes.Clear(); + } + + // ── JSON 변환 ──────────────────────────────────────────────── + // JsonUtility가 Dictionary/HashSet을 직렬화하지 못해 리스트로 바꿔 저장한다. + [Serializable] + private class JsonData + { + public int MainProgress; + public List AffectionIds = new(); + public List AffectionValues = new(); + public List CompletedDialogs = new(); + public List ChosenCodes = new(); + } + + public string ToJson() + { + var data = new JsonData { MainProgress = MainProgress }; + foreach (var kvp in Affection) + { + data.AffectionIds.Add(kvp.Key); + data.AffectionValues.Add(kvp.Value); + } + data.CompletedDialogs.AddRange(CompletedDialogs); + data.ChosenCodes.AddRange(ChosenCodes); + return JsonUtility.ToJson(data, prettyPrint: true); + } + + // json이 유효하지 않으면 null 반환 + public static StoryState FromJson(string json) + { + var data = JsonUtility.FromJson(json); + if (data == null) return null; + + var state = new StoryState { MainProgress = data.MainProgress }; + for (int i = 0; i < data.AffectionIds.Count && i < data.AffectionValues.Count; i++) + state.Affection[data.AffectionIds[i]] = data.AffectionValues[i]; + state.CompletedDialogs.UnionWith(data.CompletedDialogs); + state.ChosenCodes.UnionWith(data.ChosenCodes); + return state; + } +} diff --git a/Assets/02_Scripts/Managers/StoryState.cs.meta b/Assets/02_Scripts/Story/StoryState.cs.meta similarity index 100% rename from Assets/02_Scripts/Managers/StoryState.cs.meta rename to Assets/02_Scripts/Story/StoryState.cs.meta diff --git a/Assets/04_Models/GlobalManagers.prefab b/Assets/04_Models/GlobalManagers.prefab new file mode 100644 index 00000000..10c030ac --- /dev/null +++ b/Assets/04_Models/GlobalManagers.prefab @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21e48baa493c6715c2dfc82ed9a014dd555394b339366370c52ec302f46d1e63 +size 11847 diff --git a/Assets/11_Audio/dumy.txt.meta b/Assets/04_Models/GlobalManagers.prefab.meta similarity index 62% rename from Assets/11_Audio/dumy.txt.meta rename to Assets/04_Models/GlobalManagers.prefab.meta index f653b029..54c74c43 100644 --- a/Assets/11_Audio/dumy.txt.meta +++ b/Assets/04_Models/GlobalManagers.prefab.meta @@ -1,6 +1,6 @@ fileFormatVersion: 2 -guid: 4295b27a1fa8a0b40871de6ca150df1f -TextScriptImporter: +guid: 025a2182ebe600a4cb21d80e5cda93df +PrefabImporter: externalObjects: {} userData: assetBundleName: diff --git a/Assets/04_Models/dumy.txt b/Assets/04_Models/dumy.txt deleted file mode 100644 index ce0b02a8..00000000 --- a/Assets/04_Models/dumy.txt +++ /dev/null @@ -1 +0,0 @@ -dumy \ No newline at end of file diff --git a/Assets/11_Audio/MainMixer.mixer b/Assets/11_Audio/MainMixer.mixer new file mode 100644 index 00000000..9539be76 --- /dev/null +++ b/Assets/11_Audio/MainMixer.mixer @@ -0,0 +1,142 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!243 &-6754184347155731858 +AudioMixerGroupController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: BGM + m_AudioMixer: {fileID: 24100000} + m_GroupID: 5f7a54950e921514597efabce8ad9538 + m_Children: [] + m_Volume: 51a45267944f16c48bd0c1b47674c9c1 + m_Pitch: 0432219ffb2de904eb51b08e7b4039c1 + m_Send: 00000000000000000000000000000000 + m_Effects: + - {fileID: -1111153668822042352} + m_UserColorIndex: 0 + m_Mute: 0 + m_Solo: 0 + m_BypassEffects: 0 +--- !u!244 &-1496916555325082197 +AudioMixerEffectController: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_EffectID: 68da5166ea21bb94cbec906753adafa5 + m_EffectName: Attenuation + m_MixLevel: d6c6d88f5807fe9429cc6d012351dffb + m_Parameters: [] + m_SendTarget: {fileID: 0} + m_EnableWetMix: 0 + m_Bypass: 0 +--- !u!244 &-1111153668822042352 +AudioMixerEffectController: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_EffectID: 0178421faec543141ada37a78498bdff + m_EffectName: Attenuation + m_MixLevel: 49c759871323a304a86250f0bccc3e89 + m_Parameters: [] + m_SendTarget: {fileID: 0} + m_EnableWetMix: 0 + m_Bypass: 0 +--- !u!241 &24100000 +AudioMixerController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: MainMixer + m_OutputGroup: {fileID: 0} + m_MasterGroup: {fileID: 24300002} + m_Snapshots: + - {fileID: 24500006} + m_StartSnapshot: {fileID: 24500006} + m_SuspendThreshold: -80 + m_EnableSuspend: 1 + m_UpdateMode: 0 + m_ExposedParameters: + - guid: 51a45267944f16c48bd0c1b47674c9c1 + name: BGMVolume + - guid: e7d5a562f07ab704297aae40c95eb2d1 + name: SFXVolume + m_AudioMixerGroupViews: + - guids: + - b7bf8477d3683044bab436ed49001ce7 + - 5f7a54950e921514597efabce8ad9538 + - 00fb9f3f9e7d98846a8c3ad043a8c007 + name: View + m_CurrentViewIndex: 0 + m_TargetSnapshot: {fileID: 24500006} +--- !u!243 &24300002 +AudioMixerGroupController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Master + m_AudioMixer: {fileID: 24100000} + m_GroupID: b7bf8477d3683044bab436ed49001ce7 + m_Children: + - {fileID: -6754184347155731858} + - {fileID: 3851611846971593479} + m_Volume: c4255cb7b5c23704d92df287bff3135e + m_Pitch: 8a954a14461e63f428752c6604c6591d + m_Send: 00000000000000000000000000000000 + m_Effects: + - {fileID: 24400004} + m_UserColorIndex: 0 + m_Mute: 0 + m_Solo: 0 + m_BypassEffects: 0 +--- !u!244 &24400004 +AudioMixerEffectController: + m_ObjectHideFlags: 3 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: + m_EffectID: cef3cb8bb6e71844ca4a0a1a8a3b622b + m_EffectName: Attenuation + m_MixLevel: 8e4408bf002cf774d8781dc5d086c342 + m_Parameters: [] + m_SendTarget: {fileID: 0} + m_EnableWetMix: 0 + m_Bypass: 0 +--- !u!245 &24500006 +AudioMixerSnapshotController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: Snapshot + m_AudioMixer: {fileID: 24100000} + m_SnapshotID: 65a440c2a830da0468487d2a92c4cb88 + m_FloatValues: {} + m_TransitionOverrides: {} +--- !u!243 &3851611846971593479 +AudioMixerGroupController: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: SFX + m_AudioMixer: {fileID: 24100000} + m_GroupID: 00fb9f3f9e7d98846a8c3ad043a8c007 + m_Children: [] + m_Volume: e7d5a562f07ab704297aae40c95eb2d1 + m_Pitch: 7f222167bc918324aad889222a57cc16 + m_Send: 00000000000000000000000000000000 + m_Effects: + - {fileID: -1496916555325082197} + m_UserColorIndex: 0 + m_Mute: 0 + m_Solo: 0 + m_BypassEffects: 0 diff --git a/Assets/11_Audio/MainMixer.mixer.meta b/Assets/11_Audio/MainMixer.mixer.meta new file mode 100644 index 00000000..89ae60ab --- /dev/null +++ b/Assets/11_Audio/MainMixer.mixer.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a913377bdac526e4c942efee91cd9f36 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 24100000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/11_Audio/dumy.txt b/Assets/11_Audio/dumy.txt deleted file mode 100644 index ce0b02a8..00000000 --- a/Assets/11_Audio/dumy.txt +++ /dev/null @@ -1 +0,0 @@ -dumy \ No newline at end of file diff --git a/Assets/XR/Settings/OpenXRPackageSettings.asset b/Assets/XR/Settings/OpenXRPackageSettings.asset index 06f22f1f..6d0ed93a 100644 --- a/Assets/XR/Settings/OpenXRPackageSettings.asset +++ b/Assets/XR/Settings/OpenXRPackageSettings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b59ac0a47e88aae665a1dd71a96e0bfc88d5c4e3975bba7f664f105f099ac011 -size 108094 +oid sha256:9cf7bd0648b1bd68de9a15b9f37122b2abc9c71bfc1f37b3e41e4a5d937176b7 +size 110536