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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@@ -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) 입력까지 대기

View File

@@ -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<ParticleSystem>();
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<int> WaitForChoice(DialogNode node)

View File

@@ -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<GestureData>(gn.GetInputPortByName(DialogLineNode.PORT_GESTURE));
dn.Expression = GetInputPortValue<ExpressionData>(gn.GetInputPortByName(DialogLineNode.PORT_EXPRESSION));
dn.Voice = GetInputPortValue<VoiceClip>(gn.GetInputPortByName(DialogLineNode.PORT_VOICE));
dn.Bgm = GetInputPortValue<AudioClip>(gn.GetInputPortByName(DialogLineNode.PORT_BGM));
dn.Vfx = GetInputPortValue<GameObject>(gn.GetInputPortByName(DialogLineNode.PORT_VFX));
dn.LineDuration = GetInputPortValue<float>(gn.GetInputPortByName(DialogLineNode.PORT_DURATION));
dn.LookAtPlayer = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_LOOKAT));
dn.WaitForInput = GetInputPortValue<bool>(gn.GetInputPortByName(DialogLineNode.PORT_WAITINPUT));

View File

@@ -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<GestureData>(PORT_GESTURE).WithDisplayName("Gesture").Build();
context.AddInputPort<ExpressionData>(PORT_EXPRESSION).WithDisplayName("Expression").Build();
context.AddInputPort<VoiceClip>(PORT_VOICE).WithDisplayName("Voice").Build();
context.AddInputPort<AudioClip>(PORT_BGM).WithDisplayName("BGM")
.WithTooltip("있으면 이 대사부터 전용 BGM 재생, 비우면 기본 BGM으로 복귀").Build();
context.AddInputPort<GameObject>(PORT_VFX).WithDisplayName("VFX Prefab")
.WithTooltip("이 대사 시작 시 화자 위치에서 1회 재생할 이펙트 프리팹").Build();
context.AddInputPort<float>(PORT_DURATION).WithDisplayName("Line Duration").Build();
context.AddInputPort<bool>(PORT_LOOKAT).WithDisplayName("Look At Player").Build();
context.AddInputPort<bool>(PORT_WAITINPUT).WithDisplayName("Wait For Input").Build();

View File

@@ -0,0 +1,4 @@
public interface ISceneInitializable
{
public void OnSceneLoaded();
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 38039c1904e850146a01668e869851be

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,6 +1,7 @@
fileFormatVersion: 2
guid: b8ee6a43c79b269439e262f79b4d84c9
TextScriptImporter:
guid: 9720d44bfa10d5c4eaadac3921ee3d42
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using UnityEngine;
// 이야기 진행 상태 데이터 (메인 진행도 / 호감도 / 대화 이력 / 선택 이력).
// 상태 보관과 JSON 변환만 담당한다 — 게임 로직에서는 StoryManager를 통해 접근할 것.
public class StoryState
{
public int MainProgress;
public readonly Dictionary<string, int> Affection = new(); // 캐릭터 Id → 호감도
public readonly HashSet<string> CompletedDialogs = new(); // 완료한 DialogGroup 이름
public readonly HashSet<string> 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<string> AffectionIds = new();
public List<int> AffectionValues = new();
public List<string> CompletedDialogs = new();
public List<string> 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<JsonData>(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;
}
}

Binary file not shown.

View File

@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 4295b27a1fa8a0b40871de6ca150df1f
TextScriptImporter:
guid: 025a2182ebe600a4cb21d80e5cda93df
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:

View File

@@ -1 +0,0 @@
dumy

View File

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a913377bdac526e4c942efee91cd9f36
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 24100000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1 +0,0 @@
dumy