2026-07-05 글로벌 프리팹 추가

This commit is contained in:
2026-07-05 22:21:59 +09:00
parent 2e62e509e9
commit 46c712d2f1
29 changed files with 787 additions and 180 deletions

View File

@@ -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; }
}

View File

@@ -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<MonoBehaviour>();
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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3e48e3fcdff9c843abca3c5583a43ad

View File

@@ -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<AudioSource> _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<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);
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f3f27662344dffe4a867f63cf1605d63

View File

@@ -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<string, int> _affection = new();
private static readonly HashSet<string> _completedDialogs = new();
private static readonly HashSet<string> _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<string> AffectionIds = new();
public List<int> AffectionValues = new();
public List<string> CompletedDialogs = new();
public List<string> 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<SaveData>(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;
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d47259c7403ae7e42b37a411170bc21c