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 _initialAffections = new(); [Tooltip("플레이 시작 시 소지 아이템 — 이 씬만 직접 열어 테스트할 때 앞 과정 없이 증거품을 들고 시작하게. " + "다른 초기값과 마찬가지로 플레이를 시작한 씬의 값만 적용되고, 씬 전환으로 넘어온 경우엔 무시된다. " + "실제 게임에서의 획득은 AddItem(터치존·버튼 UnityEvent 등)으로")] [SerializeField] private List _initialItems = 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; // 테스트용 초기 소지 아이템 foreach (var item in _initialItems) if (item != null && !_state.Items.Contains(ItemIdOf(item))) _state.Items.Add(ItemIdOf(item)); } 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 IReadOnlyList ItemIds => _state.Items; public bool HasItem(ItemData item) => item != null && _state.Items.Contains(ItemIdOf(item)); // UnityEvent 연결용 (대화 터치존·월드 버튼 등에서 증거품 획득). 중복 획득은 무시된다. public void AddItem(ItemData item) { if (item == null) return; string id = ItemIdOf(item); if (_state.Items.Contains(id)) return; _state.Items.Add(id); Changed?.Invoke(); } public void RemoveItem(ItemData item) { if (item == null || !_state.Items.Remove(ItemIdOf(item))) return; Changed?.Invoke(); } // ItemData의 Id를 키로 사용 (비어있으면 에셋 이름) — 캐릭터 IdOf와 같은 규칙 private static string ItemIdOf(ItemData i) => string.IsNullOrEmpty(i.Id) ? i.name : i.Id; // ── 선택 이력 ──────────────────────────────────────────────── 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; }